Files
documenso/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
T
Lucas Smith d5ce222482 feat: add CSC AES/QES signing (v1 instance-wide config) (#2874)
Adds Cloud Signature Consortium (CSC) integration for AES/QES signing
against a configured TSP. v1 ships as instance-wide configuration via
environment variables, with per-envelope signature level selection,
license gating, and an OAuth-driven signing flow (capture + FIFO
signers, SAD session, blocking/in-progress recipient pages).

Includes signature level compatibility checks (role, signing order,
dictate next signer), envelope mutability assertions, Prisma migration
for signature level and CSC tables, and docs for the new signing
certificate options.
2026-06-16 23:37:34 +10:00

264 lines
8.5 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 } = 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;
}
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}
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"
/>
);
};