mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 09:25:08 +10:00
138d663c25
Merge origin/main into feat/external-2fa-codes. Resolve formatting conflicts caused by biome rollout; preserve both feature streams: PR's external 2FA token + signing-session 2FA proof additions plus main's RateLimit/RecipientExpired/signingReminders/date-auto-insert. In complete-document-with-token.ts, drop the duplicate early field-fetching block introduced when main moved that logic later with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH check using derivedRecipientActionAuth.
371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
|
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
|
|
import { sortFieldsByPosition } from '@documenso/lib/utils/fields';
|
|
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
|
|
import { trpc } from '@documenso/trpc/react';
|
|
import type {
|
|
TRemovedSignedFieldWithTokenMutationSchema,
|
|
TSignFieldWithTokenMutationSchema,
|
|
} from '@documenso/trpc/server/field-router/schema';
|
|
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
|
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
|
import { cn } from '@documenso/ui/lib/utils';
|
|
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 { 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 { DocumentStatus, SigningStatus } from '@prisma/client';
|
|
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { match, P } from 'ts-pattern';
|
|
|
|
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
|
|
|
|
import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider';
|
|
import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog';
|
|
import { EmbedDocumentFields } from '../embed-document-fields';
|
|
|
|
interface MultiSignDocumentSigningViewProps {
|
|
token: string;
|
|
recipientId: number;
|
|
onBack: () => void;
|
|
onDocumentCompleted?: (data: { token: string; documentId: number; recipientId: number }) => void;
|
|
onDocumentRejected?: (data: { token: string; documentId: number; recipientId: number; reason: string }) => void;
|
|
onDocumentError?: () => void;
|
|
onDocumentReady?: () => void;
|
|
isNameLocked?: boolean;
|
|
allowDocumentRejection?: boolean;
|
|
}
|
|
|
|
export const MultiSignDocumentSigningView = ({
|
|
token,
|
|
recipientId,
|
|
onBack,
|
|
onDocumentCompleted,
|
|
onDocumentRejected,
|
|
onDocumentError,
|
|
onDocumentReady,
|
|
isNameLocked = false,
|
|
allowDocumentRejection = false,
|
|
}: MultiSignDocumentSigningViewProps) => {
|
|
const { _ } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const { fullName, email, signature, setFullName, setSignature } = useRequiredDocumentSigningContext();
|
|
|
|
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
|
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
|
|
|
|
const { data: document, isLoading } = trpc.embeddingPresign.getMultiSignDocument.useQuery(
|
|
{ token },
|
|
{
|
|
staleTime: 0,
|
|
},
|
|
);
|
|
|
|
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
|
|
const { mutateAsync: removeSignedFieldWithToken } = trpc.field.removeSignedFieldWithToken.useMutation();
|
|
|
|
const { mutateAsync: completeDocumentWithToken } = trpc.recipient.completeDocumentWithToken.useMutation();
|
|
|
|
const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type));
|
|
|
|
const [pendingFields, completedFields] = [
|
|
sortFieldsByPosition(
|
|
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? [],
|
|
),
|
|
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? [],
|
|
];
|
|
|
|
const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? [];
|
|
|
|
const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => {
|
|
try {
|
|
await signFieldWithToken(payload);
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
throw error;
|
|
}
|
|
|
|
console.error(err);
|
|
|
|
toast({
|
|
title: _(msg`Error`),
|
|
description: _(msg`An error occurred while signing the document.`),
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
};
|
|
|
|
const onUnsignField = async (payload: TRemovedSignedFieldWithTokenMutationSchema) => {
|
|
try {
|
|
await removeSignedFieldWithToken(payload);
|
|
} catch (err) {
|
|
const error = AppError.parseError(err);
|
|
|
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
|
throw error;
|
|
}
|
|
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const onDocumentComplete = async () => {
|
|
try {
|
|
setIsSubmitting(true);
|
|
|
|
await completeDocumentWithToken({
|
|
documentId: document!.id,
|
|
token,
|
|
});
|
|
|
|
onBack();
|
|
|
|
onDocumentCompleted?.({
|
|
token,
|
|
documentId: document!.id,
|
|
recipientId,
|
|
});
|
|
} catch (err) {
|
|
onDocumentError?.();
|
|
|
|
toast({
|
|
title: _(msg`Error`),
|
|
description: _(msg`Failed to complete the document. Please try again.`),
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const onNextFieldClick = () => {
|
|
setShowPendingFieldTooltip(true);
|
|
|
|
setIsExpanded(false);
|
|
};
|
|
|
|
const onRejected = (reason: string) => {
|
|
if (onDocumentRejected && document) {
|
|
onDocumentRejected({
|
|
token,
|
|
documentId: document.id,
|
|
recipientId,
|
|
reason,
|
|
});
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<div className="relative h-full w-full p-8">
|
|
{match({ isLoading, document })
|
|
.with({ isLoading: true }, () => (
|
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Loader className="h-8 w-8 animate-spin text-primary" />
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Loading document...</Trans>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))
|
|
.with({ isLoading: false, document: undefined }, () => (
|
|
<div className="flex min-h-[400px] w-full items-center justify-center">
|
|
<p className="text-muted-foreground text-sm">
|
|
<Trans>Failed to load document</Trans>
|
|
</p>
|
|
</div>
|
|
))
|
|
.with({ document: P.nonNullable }, ({ document }) => (
|
|
<>
|
|
<div className="mx-auto flex w-full max-w-screen-xl items-baseline justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<h1 className="font-semibold text-2xl">{document.title}</h1>
|
|
</div>
|
|
|
|
<Button variant="ghost" size="sm" onClick={onBack} className="p-2">
|
|
<X className="h-5 w-5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{allowDocumentRejection && (
|
|
<div className="embed--Actions mt-8 mb-4 flex w-full flex-row-reverse items-baseline justify-between">
|
|
<DocumentSigningRejectDialog documentId={document.id} token={token} onRejected={onRejected} />
|
|
</div>
|
|
)}
|
|
|
|
<div className="embed--DocumentContainer relative mx-auto mt-8 flex w-full max-w-screen-xl flex-col gap-x-6 gap-y-12 md:flex-row">
|
|
<div
|
|
className={cn('embed--DocumentViewer flex-1', {
|
|
'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED,
|
|
})}
|
|
>
|
|
<PDFViewerLazy
|
|
data={getDocumentDataUrlForPdfViewer({
|
|
envelopeId: document.envelopeId,
|
|
envelopeItemId: document.envelopeItems[0]?.id,
|
|
documentDataId: document.documentData.id,
|
|
version: 'current',
|
|
token,
|
|
presignToken: undefined,
|
|
})}
|
|
scrollParentRef="window"
|
|
onDocumentLoad={() => {
|
|
setHasDocumentLoaded(true);
|
|
onDocumentReady?.();
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Widget */}
|
|
{document.status !== DocumentStatus.COMPLETED && (
|
|
<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-0 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">
|
|
<Trans>Sign document</Trans>
|
|
</h3>
|
|
|
|
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
|
{isExpanded ? (
|
|
<LucideChevronDown
|
|
className="h-5 w-5 text-muted-foreground"
|
|
onClick={() => setIsExpanded(false)}
|
|
/>
|
|
) : (
|
|
<LucideChevronUp
|
|
className="h-5 w-5 text-muted-foreground"
|
|
onClick={() => setIsExpanded(true)}
|
|
/>
|
|
)}
|
|
</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">
|
|
<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">
|
|
{
|
|
<>
|
|
<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} disabled />
|
|
</div>
|
|
|
|
{hasSignatureField && (
|
|
<div>
|
|
<Label htmlFor="Signature">
|
|
<Trans>Signature</Trans>
|
|
</Label>
|
|
|
|
<SignaturePadDialog
|
|
className="mt-2"
|
|
disabled={isSubmitting}
|
|
disableAnimation
|
|
fullName={fullName}
|
|
value={signature ?? ''}
|
|
onChange={(v) => setSignature(v ?? '')}
|
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
drawSignatureEnabled={document.documentMeta?.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">
|
|
{uninsertedFields.length > 0 ? (
|
|
<Button className="col-start-2" onClick={onNextFieldClick}>
|
|
<Trans>Next</Trans>
|
|
</Button>
|
|
) : (
|
|
<Button className="col-span-2" loading={isSubmitting} onClick={onDocumentComplete}>
|
|
<Trans>Complete</Trans>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasDocumentLoaded && 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 */}
|
|
{hasDocumentLoaded && (
|
|
<EmbedDocumentFields
|
|
fields={pendingFields}
|
|
metadata={document.documentMeta}
|
|
onSignField={onSignField}
|
|
onUnsignField={onUnsignField}
|
|
/>
|
|
)}
|
|
|
|
{/* Completed fields */}
|
|
{document.status !== DocumentStatus.COMPLETED && (
|
|
<DocumentReadOnlyFields documentMeta={document.documentMeta ?? undefined} fields={completedFields} />
|
|
)}
|
|
</div>
|
|
</>
|
|
))
|
|
.otherwise(() => null)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|