mirror of
https://github.com/documenso/documenso.git
synced 2026-08-19 21:11:54 +10:00
fix: surface actionable errors when completing documents (#3229)
This commit is contained in:
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+17
-1
@@ -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',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
+4
-11
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, { code: string;
|
||||
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.ENVELOPE_TSP_LOCKED]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.MISSING_SIGNATURE_FIELD]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.RECIPIENT_OUT_OF_TURN]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_INSTANCE_MODE_MISMATCH]: { code: 'BAD_REQUEST', status: 400 },
|
||||
[AppErrorCode.CSC_UNLICENSED]: { code: 'FORBIDDEN', status: 403 },
|
||||
[AppErrorCode.CSC_PROVIDER_INFO_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
|
||||
@@ -307,6 +323,8 @@ export class AppError extends Error {
|
||||
AppErrorCode.ENVELOPE_LEGACY,
|
||||
AppErrorCode.ENVELOPE_TSP_LOCKED,
|
||||
AppErrorCode.MISSING_SIGNATURE_FIELD,
|
||||
AppErrorCode.RECIPIENT_HAS_UNSIGNED_FIELDS,
|
||||
AppErrorCode.RECIPIENT_OUT_OF_TURN,
|
||||
AppErrorCode.CSC_INSTANCE_MODE_MISMATCH,
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
|
||||
@@ -59,7 +59,7 @@ export const completeDocumentWithToken = async ({
|
||||
nextSigner,
|
||||
recipientOverride,
|
||||
}: CompleteDocumentWithTokenOptions) => {
|
||||
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, AppErrorCode> = {
|
||||
[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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user