Files
documenso/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
T
Lucas Smith fc95ee9ead fix: handle completion when already signed (#3159)
Previously attempting to complete a document which is already completed
you'd get a generic error toast. Now when completing a document that you
have already completed you are redirected to the completed page.

Handles cases where two mutations managed to fire racing eachother.
2026-08-10 09:25:41 +10:00

278 lines
9.0 KiB
TypeScript

import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { useMemo } from 'react';
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export const EnvelopeSignerCompleteDialog = () => {
const navigate = useNavigate();
const analytics = useAnalytics();
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [searchParams] = useSearchParams();
const {
isDirectTemplate,
envelope,
setShowPendingFieldTooltip,
recipientFieldsRemaining,
recipient,
nextRecipient,
email,
fullName,
} = useRequiredEnvelopeSigningContext();
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
const {
mutateAsync: completeDocument,
isPending,
isSuccess,
} = trpc.recipient.completeDocumentWithToken.useMutation();
const { mutateAsync: createDocumentFromDirectTemplate } =
trpc.template.createDocumentFromDirectTemplate.useMutation();
const handleOnNextFieldClick = () => {
const nextField = recipientFieldsRemaining[0];
if (!nextField) {
setShowPendingFieldTooltip(false);
return;
}
const isEnvelopeItemSwitch = nextField.envelopeItemId !== currentEnvelopeItem?.id;
if (isEnvelopeItemSwitch) {
setCurrentEnvelopeItem(nextField.envelopeItemId);
}
setShowPendingFieldTooltip(true);
setTimeout(
() => {
const fieldTooltip = document.querySelector(`#field-tooltip`);
if (fieldTooltip) {
fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
// Tooltip not in DOM (page virtualized away) — signal the PDF viewer
// to scroll to the correct page via the data attribute.
const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR);
if (pdfContent) {
pdfContent.setAttribute('data-scroll-to-page', String(nextField.page));
}
}
},
isEnvelopeItemSwitch ? 150 : 50,
);
};
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
const result = await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
recipientOverride: recipientDetails,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
// TSP envelopes can't be completed via the SES path; the mutation returns
// a credential-scope OAuth URL the recipient must follow to acquire a SAD
// before the sync sign mutation can run. Short-circuit here so the
// analytics / completion handlers don't run with a still-unsigned doc.
if (result.status === 'REDIRECT') {
window.location.href = result.redirectUrl;
return;
}
// The document was already completed by an earlier request (retry,
// stale tab or concurrent submission). Let the user know this click
// didn't complete the document, then continue to the completed page.
if (result.status === 'ALREADY_SIGNED') {
toast({
title: t`Document already signed`,
description: t`This document was already signed and no further action was taken.`,
});
} else {
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
}
if (onDocumentCompleted) {
onDocumentCompleted({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
recipientId: recipient.id,
envelopeId: envelope.id,
});
await revalidate();
return;
}
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
window.location.href = `/sign/${recipient.token}/complete`;
}
} catch (err) {
const error = AppError.parseError(err);
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
}
throw err;
}
};
/**
* Direct template completion flow.
*/
const handleDirectTemplateCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
if (!recipient.directToken) {
throw new Error('Recipient direct token is required');
}
const { token } = await createDocumentFromDirectTemplate({
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
directTemplateExternalId,
directRecipientName: recipientDetails?.name || fullName,
directRecipientEmail: recipientDetails?.email || email,
templateUpdatedAt: envelope.updatedAt,
signedFieldValues: recipient.fields.map((field) => {
let value = field.customText;
let isBase64 = false;
if (field.type === FieldType.SIGNATURE && field.signature) {
value = field.signature.signatureImageAsBase64 || field.signature.typedSignature || '';
isBase64 = isBase64Image(value);
}
return {
token: '',
fieldId: field.id,
value,
isBase64,
};
}),
nextSigner,
});
const redirectUrl = envelope.documentMeta.redirectUrl;
if (onDocumentCompleted) {
await navigate({
pathname: `/embed/sign/${token}`,
search: window.location.search,
hash: window.location.hash,
});
return;
}
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
window.location.href = `/sign/${token}/complete`;
}
} catch (err) {
console.log('err', err);
toast({
title: t`Something went wrong`,
description: t`We were unable to submit this document at this time. Please try again later.`,
variant: 'destructive',
});
onDocumentError?.();
throw err;
}
};
const recipientPayload = useMemo(() => {
if (!isDirectTemplate) {
return {
name:
recipient.name ||
fullName ||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
'',
email:
recipient.email ||
email ||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
'',
};
}
return {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending || isSuccess}
recipientPayload={recipientPayload}
onSignatureComplete={isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick}
documentTitle={envelope.title}
fields={recipientFieldsRemaining}
fieldsValidated={handleOnNextFieldClick}
recipient={recipient}
allowDictateNextSigner={Boolean(nextRecipient && envelope.documentMeta.allowDictateNextSigner)}
disableNameInput={!isDirectTemplate && recipient.name !== ''}
defaultNextSigner={nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined}
buttonSize="sm"
position="center"
/>
);
};