mirror of
https://github.com/documenso/documenso.git
synced 2026-09-29 16:24:30 +10:00
Hides the reject dialog on the V2 signing page sidebar and mobile menu, the embed V1 signing page and the multi-sign embed view when the document's allowDocumentRejection is false. In embeds the reject option is shown only when both the embed's allowDocumentRejection option and the document setting allow it, and the embed V1 footer layout follows the same condition. Exposes the field in the multi-sign document response so the embed view can read it. Documents the relationship between the embed's allowDocumentRejection option and the document setting in the iframe and embedded editor docs, and in the API description of the field. Part of #2561
547 lines
21 KiB
TypeScript
547 lines
21 KiB
TypeScript
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
|
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';
|
|
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
|
|
import { dynamicActivate } from '@documenso/lib/utils/i18n';
|
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
|
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
|
import { trpc } from '@documenso/trpc/react';
|
|
import {
|
|
type DocumentField,
|
|
DocumentReadOnlyFields,
|
|
} from '@documenso/ui/components/document/document-read-only-fields';
|
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
|
import { Input } from '@documenso/ui/primitives/input';
|
|
import { Label } from '@documenso/ui/primitives/label';
|
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { useLingui } from '@lingui/react';
|
|
import { Trans } from '@lingui/react/macro';
|
|
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
|
import { type Field, RecipientRole, SigningStatus } from '@prisma/client';
|
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
|
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';
|
|
import { DocumentSigningRecipientProvider } from '../general/document-signing/document-signing-recipient-provider';
|
|
import { DocumentSigningRejectDialog } from '../general/document-signing/document-signing-reject-dialog';
|
|
import { EmbedClientLoading } from './embed-client-loading';
|
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
|
import { EmbedDocumentFields } from './embed-document-fields';
|
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
|
|
|
export type EmbedSignDocumentV1ClientPageProps = {
|
|
token: string;
|
|
documentId: number;
|
|
envelopeId: string;
|
|
envelopeItems: (Pick<EnvelopeItem, 'id' | 'envelopeId'> & { documentData: { id: string } })[];
|
|
recipient: RecipientWithFields;
|
|
fields: Field[];
|
|
completedFields: DocumentField[];
|
|
metadata?: DocumentMeta | null;
|
|
isCompleted?: boolean;
|
|
hidePoweredBy?: boolean;
|
|
allowWhitelabelling?: boolean;
|
|
allRecipients?: RecipientWithFields[];
|
|
};
|
|
|
|
export const EmbedSignDocumentV1ClientPage = ({
|
|
token,
|
|
documentId,
|
|
envelopeId,
|
|
envelopeItems,
|
|
recipient,
|
|
fields,
|
|
completedFields,
|
|
metadata,
|
|
isCompleted,
|
|
hidePoweredBy = false,
|
|
allowWhitelabelling = false,
|
|
allRecipients = [],
|
|
}: EmbedSignDocumentV1ClientPageProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
const analytics = useAnalytics();
|
|
|
|
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
|
|
|
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
const [hasCompletedDocument, setHasCompletedDocument] = useState(isCompleted);
|
|
const [hasRejectedDocument, setHasRejectedDocument] = useState(recipient.signingStatus === SigningStatus.REJECTED);
|
|
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(
|
|
allRecipients.length > 0 ? allRecipients[0].id : null,
|
|
);
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
|
const [isEmailLocked, setIsEmailLocked] = useState(!!email);
|
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
const [_showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] = useState(false);
|
|
|
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
|
|
|
// Both the embed host and the document settings must allow rejection.
|
|
const isDocumentRejectionAllowed = allowDocumentRejection && (metadata?.allowDocumentRejection ?? true);
|
|
|
|
const selectedSigner = allRecipients.find((r) => r.id === selectedSignerId);
|
|
const isAssistantMode = recipient.role === RecipientRole.ASSISTANT;
|
|
|
|
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
|
|
|
|
const [pendingFields, _completedFields] = [
|
|
sortFieldsByPosition(
|
|
fields.filter((field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field)),
|
|
),
|
|
fields.filter((field) => field.inserted),
|
|
];
|
|
|
|
const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } =
|
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
|
|
|
const fieldsRequiringValidation = useMemo(() => fields.filter(isFieldUnsignedAndRequired), [fields]);
|
|
|
|
const hasSignatureField = fields.some((field) => isSignatureFieldType(field.type));
|
|
|
|
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
|
|
|
|
const assistantSignersId = useId();
|
|
|
|
const onNextFieldClick = () => {
|
|
validateFieldsInserted(fieldsRequiringValidation);
|
|
|
|
setShowPendingFieldTooltip(true);
|
|
setIsExpanded(false);
|
|
};
|
|
|
|
const onCompleteClick = async () => {
|
|
try {
|
|
const valid = validateFieldsInserted(fieldsRequiringValidation);
|
|
|
|
if (!valid) {
|
|
setShowPendingFieldTooltip(true);
|
|
return;
|
|
}
|
|
|
|
await completeDocumentWithToken({
|
|
documentId,
|
|
token,
|
|
});
|
|
|
|
if (window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'document-completed',
|
|
data: {
|
|
token,
|
|
documentId,
|
|
recipientId: recipient.id,
|
|
},
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
|
|
setHasCompletedDocument(true);
|
|
} catch (err) {
|
|
analytics.captureException(err, {
|
|
source: 'embed',
|
|
location: 'complete_document',
|
|
recipientId: recipient.id,
|
|
documentId,
|
|
envelopeId,
|
|
});
|
|
|
|
if (window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'document-error',
|
|
data: null,
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
|
|
const error = AppError.parseError(err);
|
|
const toastMessage = getSigningCompletionErrorMessage(error.code);
|
|
|
|
toast({
|
|
title: _(toastMessage.title),
|
|
description: _(toastMessage.description),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const onDocumentRejected = (reason: string) => {
|
|
if (window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'document-rejected',
|
|
data: {
|
|
token,
|
|
documentId,
|
|
recipientId: recipient.id,
|
|
reason,
|
|
},
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
|
|
setHasRejectedDocument(true);
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
const hash = window.location.hash.slice(1);
|
|
|
|
try {
|
|
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
|
|
|
if (!isCompleted && data.name && !fullName) {
|
|
setFullName(data.name);
|
|
}
|
|
|
|
// Since a recipient can be provided a name we can lock it without requiring
|
|
// a to be provided by the parent application, unlike direct templates.
|
|
setIsNameLocked(!!data.lockName);
|
|
|
|
if (!isCompleted && data.email && !email) {
|
|
setEmail(data.email);
|
|
setIsEmailLocked(!!data.lockEmail);
|
|
}
|
|
|
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
|
setShowOtherRecipientsCompletedFields(!!data.showOtherRecipientsCompletedFields);
|
|
|
|
if (data.darkModeDisabled) {
|
|
document.documentElement.classList.add('dark-mode-disabled');
|
|
}
|
|
|
|
if (allowWhitelabelling) {
|
|
injectCss({
|
|
css: data.css,
|
|
cssVars: data.cssVars,
|
|
});
|
|
}
|
|
|
|
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
|
|
void dynamicActivate(data.language).finally(() => {
|
|
setHasFinishedInit(true);
|
|
});
|
|
} else {
|
|
setHasFinishedInit(true);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
|
|
analytics.captureException(err, {
|
|
source: 'embed',
|
|
location: 'embed_init',
|
|
recipientId: recipient.id,
|
|
documentId,
|
|
envelopeId,
|
|
});
|
|
|
|
setHasFinishedInit(true);
|
|
}
|
|
|
|
// !: While the two setters are stable we still want to ensure we're avoiding
|
|
// !: re-renders.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (hasFinishedInit && hasDocumentLoaded && window.parent) {
|
|
window.parent.postMessage(
|
|
{
|
|
action: 'document-ready',
|
|
data: null,
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
}, [hasFinishedInit, hasDocumentLoaded]);
|
|
|
|
if (hasRejectedDocument) {
|
|
return <EmbedDocumentRejected />;
|
|
}
|
|
|
|
if (hasCompletedDocument) {
|
|
return (
|
|
<EmbedDocumentCompleted
|
|
name={fullName}
|
|
signature={{
|
|
id: 1,
|
|
fieldId: 1,
|
|
recipientId: 1,
|
|
created: new Date(),
|
|
signatureImageAsBase64: signature?.startsWith('data:') ? signature : null,
|
|
typedSignature: signature?.startsWith('data:') ? null : signature,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={selectedSigner ?? null}>
|
|
<div className="embed--Root relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
|
|
{(!hasFinishedInit || !hasDocumentLoaded) && <EmbedClientLoading />}
|
|
|
|
<div className="embed--Actions mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
|
<DocumentSigningAttachmentsPopover envelopeId={envelopeId} token={token} />
|
|
|
|
{isDocumentRejectionAllowed && (
|
|
<DocumentSigningRejectDialog documentId={documentId} token={token} onRejected={onDocumentRejected} />
|
|
)}
|
|
</div>
|
|
|
|
<div className="embed--DocumentContainer relative flex w-full flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
{/* Viewer */}
|
|
<div className="embed--DocumentViewer flex-1">
|
|
<PDFViewerLazy
|
|
data={getDocumentDataUrlForPdfViewer({
|
|
envelopeId: envelopeItems[0]?.envelopeId,
|
|
envelopeItemId: envelopeItems[0]?.id,
|
|
documentDataId: envelopeItems[0]?.documentData.id,
|
|
version: 'current',
|
|
token: token,
|
|
presignToken: undefined,
|
|
})}
|
|
scrollParentRef="window"
|
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Widget */}
|
|
<div
|
|
key={isExpanded ? 'expanded' : 'collapsed'}
|
|
className="embed--DocumentWidgetContainer group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
|
|
data-expanded={isExpanded || undefined}
|
|
>
|
|
<div className="embed--DocumentWidget flex w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:py-6">
|
|
{/* Header */}
|
|
<div className="embed--DocumentWidgetHeader">
|
|
<div className="flex items-center justify-between gap-x-2">
|
|
<h3 className="font-semibold text-foreground text-xl md:text-2xl">
|
|
{isAssistantMode ? <Trans>Assist with signing</Trans> : <Trans>Sign document</Trans>}
|
|
</h3>
|
|
|
|
{isExpanded ? (
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
|
|
onClick={() => setIsExpanded(false)}
|
|
>
|
|
<LucideChevronDown className="h-5 w-5 text-muted-foreground dark:text-background" />
|
|
</Button>
|
|
) : pendingFields.length > 0 ? (
|
|
<Button
|
|
variant="outline"
|
|
className="h-8 w-8 bg-background p-0 md:hidden dark:bg-foreground"
|
|
onClick={() => setIsExpanded(true)}
|
|
>
|
|
<LucideChevronUp className="h-5 w-5 text-muted-foreground dark:text-background" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="md:hidden"
|
|
disabled={isThrottled || (!isAssistantMode && hasSignatureField && !signatureValid)}
|
|
loading={isSubmitting}
|
|
onClick={() => throttledOnCompleteClick()}
|
|
>
|
|
<Trans>Complete</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
|
|
<p className="mt-2 text-muted-foreground text-sm">
|
|
{isAssistantMode ? (
|
|
<Trans>Help complete the document for other signers.</Trans>
|
|
) : (
|
|
<Trans>Sign the document to complete the process.</Trans>
|
|
)}
|
|
</p>
|
|
|
|
<hr className="mt-4 mb-8 border-border" />
|
|
</div>
|
|
|
|
{/* Form */}
|
|
<div className="embed--DocumentWidgetForm -mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
|
<div className="flex flex-1 flex-col gap-y-4">
|
|
{isAssistantMode && (
|
|
<div>
|
|
<Label>
|
|
<Trans>Signing for</Trans>
|
|
</Label>
|
|
|
|
<fieldset className="mt-2 rounded-2xl border border-border bg-white p-3 dark:bg-background">
|
|
<RadioGroup
|
|
className="gap-0 space-y-3 shadow-none"
|
|
value={selectedSignerId?.toString()}
|
|
onValueChange={(value) => setSelectedSignerId(Number(value))}
|
|
>
|
|
{allRecipients
|
|
.filter((r) => r.fields.length > 0)
|
|
.map((r) => (
|
|
<div
|
|
key={`${assistantSignersId}-${r.id}`}
|
|
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<RadioGroupItem
|
|
id={`${assistantSignersId}-${r.id}`}
|
|
value={r.id.toString()}
|
|
className="after:absolute after:inset-0"
|
|
/>
|
|
|
|
<div className="grid grow gap-1">
|
|
<Label
|
|
className="inline-flex items-start"
|
|
htmlFor={`${assistantSignersId}-${r.id}`}
|
|
>
|
|
{r.name}
|
|
|
|
{r.id === recipient.id && (
|
|
<span className="ml-2 text-muted-foreground">{_(msg`(You)`)}</span>
|
|
)}
|
|
</Label>
|
|
<p className="text-muted-foreground text-xs">{r.email}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-muted-foreground text-xs leading-[inherit]">
|
|
{r.fields.length} {r.fields.length === 1 ? 'field' : 'fields'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</RadioGroup>
|
|
</fieldset>
|
|
</div>
|
|
)}
|
|
|
|
{!isAssistantMode && (
|
|
<>
|
|
<div>
|
|
<Label htmlFor="full-name">
|
|
<Trans>Full Name</Trans>
|
|
</Label>
|
|
|
|
<Input
|
|
type="text"
|
|
id="full-name"
|
|
className="mt-2 bg-background"
|
|
disabled={isNameLocked}
|
|
value={fullName}
|
|
onChange={(e) => !isNameLocked && setFullName(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="email">
|
|
<Trans>Email</Trans>
|
|
</Label>
|
|
|
|
<Input
|
|
type="email"
|
|
id="email"
|
|
className="mt-2 bg-background"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
disabled={isEmailLocked}
|
|
/>
|
|
</div>
|
|
|
|
{hasSignatureField && (
|
|
<div>
|
|
<Label htmlFor="Signature">
|
|
<Trans>Signature</Trans>
|
|
</Label>
|
|
|
|
<SignaturePadDialog
|
|
className="mt-2"
|
|
disabled={isThrottled || isSubmitting}
|
|
disableAnimation
|
|
fullName={fullName}
|
|
value={signature ?? ''}
|
|
onChange={(v) => setSignature(v ?? '')}
|
|
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
|
uploadSignatureEnabled={metadata?.uploadSignatureEnabled}
|
|
drawSignatureEnabled={metadata?.drawSignatureEnabled}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="hidden flex-1 group-data-[expanded]/document-widget:block md:block" />
|
|
|
|
<div className="embed--DocumentWidgetFooter mt-4 hidden w-full grid-cols-2 items-center group-data-[expanded]/document-widget:grid md:grid">
|
|
{pendingFields.length > 0 ? (
|
|
<Button className="col-start-2" onClick={() => onNextFieldClick()}>
|
|
<Trans>Next</Trans>
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
className={isDocumentRejectionAllowed ? 'col-start-2' : 'col-span-2'}
|
|
disabled={isThrottled}
|
|
loading={isSubmitting}
|
|
onClick={() => throttledOnCompleteClick()}
|
|
>
|
|
<Trans>Complete</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showPendingFieldTooltip && pendingFields.length > 0 && (
|
|
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}>
|
|
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
|
|
<Trans>Click to insert field</Trans>
|
|
</FieldToolTip>
|
|
</ElementVisible>
|
|
)}
|
|
|
|
{/* Fields */}
|
|
<EmbedDocumentFields fields={fields} metadata={metadata} />
|
|
|
|
{/* Completed fields */}
|
|
<DocumentReadOnlyFields documentMeta={metadata || undefined} fields={completedFields} />
|
|
</div>
|
|
|
|
{!hidePoweredBy && (
|
|
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 font-medium text-primary-foreground text-xs opacity-60 hover:opacity-100">
|
|
<span>
|
|
<Trans>Powered by</Trans>
|
|
</span>
|
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DocumentSigningRecipientProvider>
|
|
);
|
|
};
|