diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index 669f5274c..c8726ae22 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -1,6 +1,7 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { AppError } from '@documenso/lib/errors/app-error'; import { ZSignDocumentEmbedDataSchema } from '@documenso/lib/types/embed-document-sign-schema'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; @@ -32,6 +33,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react'; import { BrandingLogo } from '~/components/general/branding-logo'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; import { injectCss } from '~/utils/css-vars'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; import { DocumentSigningAttachmentsPopover } from '../general/document-signing/document-signing-attachments-popover'; import { useRequiredDocumentSigningContext } from '../general/document-signing/document-signing-provider'; @@ -162,9 +164,12 @@ export const EmbedSignDocumentV1ClientPage = ({ ); } + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Something went wrong`), - description: _(msg`We were unable to submit this document at this time. Please try again later.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); } diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index b144d1c46..15902715d 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -26,6 +26,7 @@ import { useState } from 'react'; import { match, P } from 'ts-pattern'; import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; @@ -141,9 +142,12 @@ export const MultiSignDocumentSigningView = ({ } catch (err) { onDocumentError?.(); + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Error`), - description: _(msg`Failed to complete the document. Please try again.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); } finally { diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index ee4b0b626..41abb63f1 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -14,6 +14,7 @@ import { } from '@documenso/ui/primitives/dialog'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import type { Field, Recipient } from '@prisma/client'; @@ -27,6 +28,8 @@ import { useEmbedSigningContext } from '~/components/embed/embed-signing-context import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; + import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; export type DocumentSigningCompleteDialogProps = { @@ -85,7 +88,8 @@ export const DocumentSigningCompleteDialog = ({ position, disableNameInput = false, }: DocumentSigningCompleteDialogProps) => { - const { t } = useLingui(); + const { t, i18n } = useLingui(); + const { toast } = useToast(); const [showDialog, setShowDialog] = useState(false); @@ -174,6 +178,18 @@ export const DocumentSigningCompleteDialog = ({ return; } + + // This dialog owns the completion error toast for every signing surface + // so the user gets a specific, actionable message. Callers should run + // their own side effects (e.g. embeds posting document-error) and + // rethrow rather than toasting themselves. + const toastMessage = getSigningCompletionErrorMessage(err.code); + + toast({ + title: i18n._(toastMessage.title), + description: i18n._(toastMessage.description), + variant: 'destructive', + }); } }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index cbe467f36..2d3b70540 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -1,3 +1,4 @@ +import { AppError } from '@documenso/lib/errors/app-error'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; @@ -19,6 +20,8 @@ import { useId, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; +import { getSigningCompletionErrorMessage } from '~/utils/toast-error-messages'; + import { AssistantConfirmationDialog, type NextSigner } from '../../dialogs/assistant-confirmation-dialog'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { useRequiredDocumentSigningContext } from './document-signing-provider'; @@ -100,9 +103,12 @@ export const DocumentSigningForm = ({ try { await completeDocument({ nextSigner }); } catch (err) { + const error = AppError.parseError(err); + const toastMessage = getSigningCompletionErrorMessage(error.code); + toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while completing the document. Please try again.`), + title: _(toastMessage.title), + description: _(toastMessage.description), variant: 'destructive', }); diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx index 53f125c7f..29f1dbeee 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx @@ -148,15 +148,11 @@ export const EnvelopeSignerCompleteDialog = () => { 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?.(); } + // Rethrow so DocumentSigningCompleteDialog can handle 2FA retries and + // toast a specific completion error message. throw err; } }; @@ -224,14 +220,11 @@ export const EnvelopeSignerCompleteDialog = () => { } } 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?.(); + // Rethrow so DocumentSigningCompleteDialog can toast a specific + // completion error message. throw err; } }; diff --git a/apps/remix/app/utils/toast-error-messages.ts b/apps/remix/app/utils/toast-error-messages.ts index 462f42694..49e4ceb62 100644 --- a/apps/remix/app/utils/toast-error-messages.ts +++ b/apps/remix/app/utils/toast-error-messages.ts @@ -42,6 +42,51 @@ export const getDirectTemplateErrorMessage = (code: string): ToastMessageDescrip })); }; +/** + * Toast messages for errors thrown while a recipient attempts to complete + * (sign) a document, so the user knows whether retrying can help and what to + * do next. + */ +export const getSigningCompletionErrorMessage = (code: string): ToastMessageDescriptor => { + return match(code) + .with(AppErrorCode.NOT_FOUND, () => ({ + title: msg`Document no longer available`, + description: msg`This document can no longer be signed. It may have been removed by the sender, or your signing access may have been revoked. Please contact the sender for a new signing link.`, + })) + .with(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, () => ({ + title: msg`Some fields were not saved`, + description: msg`One or more of your required fields have not been saved. Please refresh the page, complete any empty required fields, and try again.`, + })) + .with(AppErrorCode.RECIPIENT_OUT_OF_TURN, () => ({ + title: msg`It's not your turn to sign yet`, + description: msg`This document is signed in a set order and other recipients must sign before you. You will receive an email when it is your turn.`, + })) + .with(AppErrorCode.RECIPIENT_EXPIRED, () => ({ + title: msg`Signing link expired`, + description: msg`Your signing link has expired. Please contact the sender to request a new one.`, + })) + .with(AppErrorCode.ENVELOPE_COMPLETED, () => ({ + title: msg`Document already completed`, + description: msg`This document has already been completed and no further signatures can be added.`, + })) + .with(AppErrorCode.ENVELOPE_REJECTED, () => ({ + title: msg`Document rejected`, + description: msg`This document has been rejected by a recipient and can no longer be signed.`, + })) + .with(AppErrorCode.ENVELOPE_CANCELLED, () => ({ + title: msg`Document cancelled`, + description: msg`This document has been cancelled by the sender and can no longer be signed. Please contact the sender if you believe this is a mistake.`, + })) + .with(AppErrorCode.ENVELOPE_DRAFT, () => ({ + title: msg`Document not ready`, + description: msg`This document has not been sent for signing yet. Please wait for the sender to send it before signing.`, + })) + .otherwise(() => ({ + title: msg`Something went wrong`, + description: msg`We were unable to submit this document at this time. Please try again later.`, + })); +}; + export const getUploadErrorMessage = (code: string): ToastMessageDescriptor => { return match(code) .with(AppErrorCode.TOO_MANY_REQUESTS, () => FAIR_USE_LIMIT_EXCEEDED_ERROR_MESSAGE) diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index 56c068215..79d5235b2 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -43,6 +43,20 @@ export enum AppErrorCode { */ RECIPIENT_ALREADY_SIGNED = 'RECIPIENT_ALREADY_SIGNED', + /** + * A completion request was made for a recipient that still has required + * fields which have not been inserted. Usually indicates the client's field + * state is out of sync with the server (e.g. a field insert failed to + * persist before submission). + */ + RECIPIENT_HAS_UNSIGNED_FIELDS = 'RECIPIENT_HAS_UNSIGNED_FIELDS', + + /** + * A completion request was made by a recipient in a sequential signing flow + * before the preceding recipients have signed. + */ + RECIPIENT_OUT_OF_TURN = 'RECIPIENT_OUT_OF_TURN', + /** * A signer recipient does not have a signature field assigned. Thrown when * distributing an envelope or using a direct template where at least one @@ -99,6 +113,8 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record { - const envelope = await prisma.envelope.findFirstOrThrow({ + const envelope = await prisma.envelope.findFirst({ where: { ...unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), recipients: { @@ -78,10 +78,23 @@ export const completeDocumentWithToken = async ({ }, }); + // The most common cause is a stale signing page: the document was deleted, + // or the recipient was removed, after the link was opened. Surface a + // NOT_FOUND instead of leaking a Prisma P2025 as a 500. + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found for the provided signing token', + statusCode: 404, + }); + } + const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId); if (envelope.recipients.length === 0) { - throw new Error(`Document ${envelope.id} has no recipient with token ${token}`); + throw new AppError(AppErrorCode.NOT_FOUND, { + message: `Document ${envelope.id} has no recipient with the provided token`, + statusCode: 404, + }); } const [recipient] = envelope.recipients; @@ -98,7 +111,19 @@ export const completeDocumentWithToken = async ({ } if (envelope.status !== DocumentStatus.PENDING) { - throw new Error(`Document ${envelope.id} must be pending`); + const envelopeStatusErrorCode: Record = { + [DocumentStatus.DRAFT]: AppErrorCode.ENVELOPE_DRAFT, + [DocumentStatus.COMPLETED]: AppErrorCode.ENVELOPE_COMPLETED, + [DocumentStatus.REJECTED]: AppErrorCode.ENVELOPE_REJECTED, + [DocumentStatus.CANCELLED]: AppErrorCode.ENVELOPE_CANCELLED, + // Unreachable: guarded by the status check above. + [DocumentStatus.PENDING]: AppErrorCode.INVALID_REQUEST, + }; + + throw new AppError(envelopeStatusErrorCode[envelope.status], { + message: `Document ${envelope.id} must be pending to be completed, found ${envelope.status}`, + statusCode: 400, + }); } assertRecipientNotExpired(recipient); @@ -116,7 +141,10 @@ export const completeDocumentWithToken = async ({ }); if (!isRecipientsTurn) { - throw new Error(`Recipient ${recipient.id} attempted to complete the document before it was their turn`); + throw new AppError(AppErrorCode.RECIPIENT_OUT_OF_TURN, { + message: `Recipient ${recipient.id} attempted to complete the document before it was their turn`, + statusCode: 400, + }); } } @@ -279,7 +307,10 @@ export const completeDocumentWithToken = async ({ } if (fieldsContainUnsignedRequiredField(fields)) { - throw new Error(`Recipient ${recipient.id} has unsigned fields`); + throw new AppError(AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS, { + message: `Recipient ${recipient.id} has unsigned fields`, + statusCode: 400, + }); } await prisma.$transaction(async (tx) => { diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 215984706..29fff7d9c 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -603,7 +603,7 @@ export const recipientRouter = router({ // can't complete via this route — they go through the CSC sync sign // flow (`enterprise.csc.signEnvelope`). This route returns the redirect URL // for the credential-scope OAuth round-trip. - const envelope = await prisma.envelope.findFirstOrThrow({ + const envelope = await prisma.envelope.findFirst({ where: { ...unsafeBuildEnvelopeIdQuery({ type: 'documentId', id: documentId }, EnvelopeType.DOCUMENT), recipients: { some: { token } }, @@ -611,6 +611,16 @@ export const recipientRouter = router({ select: { signatureLevel: true, internalVersion: true }, }); + // The most common cause is a stale signing page: the document was + // deleted, or the recipient was removed, after the link was opened. + // Surface a NOT_FOUND instead of leaking a Prisma P2025 as a 500. + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found for the provided signing token', + statusCode: 404, + }); + } + if (isTspEnvelope(envelope)) { return await prepareCscRecipientSigning({ recipientToken: token,