feat: multisign embedding (#1823)

Adds the ability to use a multisign embedding for cases where multiple
documents need to be signed in a convenient manner.
This commit is contained in:
Lucas Smith
2025-06-05 12:58:52 +10:00
committed by GitHub
parent 695ed418e2
commit ce66da0055
14 changed files with 1257 additions and 13 deletions

View File

@ -332,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="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:z-auto md:w-[350px] md:px-0"
className="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:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex h-fit w-full flex-col rounded-xl border px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">

View File

@ -290,7 +290,7 @@ export const EmbedSignDocumentClientPage = ({
{/* 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:z-auto md:w-[350px] md:px-0"
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:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">

View File

@ -0,0 +1,182 @@
import { Trans } from '@lingui/react/macro';
import { ReadStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { ArrowRight, EyeIcon, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token';
import type { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { Progress } from '@documenso/ui/primitives/progress';
// Get the return type from getRecipientByToken
type RecipientWithFields = Awaited<ReturnType<typeof getRecipientByToken>>;
interface DocumentEnvelope {
document: DocumentAndSender;
recipient: RecipientWithFields;
}
interface MultiSignDocumentListProps {
envelopes: DocumentEnvelope[];
onDocumentSelect: (document: DocumentEnvelope['document']) => void;
}
export function MultiSignDocumentList({ envelopes, onDocumentSelect }: MultiSignDocumentListProps) {
// Calculate progress
const completedDocuments = envelopes.filter(
(envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED,
);
const totalDocuments = envelopes.length;
const progressPercentage = (completedDocuments.length / totalDocuments) * 100;
// Find next document to sign (first one that's not signed and not rejected)
const nextDocumentToSign = envelopes.find(
(envelope) =>
envelope.recipient.signingStatus !== SigningStatus.SIGNED &&
envelope.recipient.signingStatus !== SigningStatus.REJECTED,
);
const allDocumentsCompleted = completedDocuments.length === totalDocuments;
const hasAssistantOrCcRecipient = envelopes.some(
(envelope) =>
envelope.recipient.role === RecipientRole.ASSISTANT ||
envelope.recipient.role === RecipientRole.CC,
);
function handleView(doc: DocumentEnvelope['document']) {
onDocumentSelect(doc);
}
function handleNextDocument() {
if (nextDocumentToSign) {
onDocumentSelect(nextDocumentToSign.document);
}
}
if (hasAssistantOrCcRecipient) {
return (
<div className="mx-auto mt-16 flex w-full max-w-lg flex-col md:mt-16 md:rounded-2xl md:border md:px-8 md:py-16 md:shadow-lg">
<div className="flex items-center justify-center">
<XCircle className="text-destructive h-16 w-16 md:h-24 md:w-24" strokeWidth={1.2} />
</div>
<h2 className="mt-12 text-xl font-bold md:text-2xl">
<Trans>It looks like we ran into an issue!</Trans>
</h2>
<p className="text-muted-foreground mt-6">
<Trans>
One of the documents in the current bundle has a signing role that is not compatible
with the current signing experience.
</Trans>
</p>
<p className="text-muted-foreground mt-2">
<Trans>
Assistants and Copy roles are currently not compatible with the multi-sign experience.
</Trans>
</p>
<p className="text-muted-foreground mt-2">
<Trans>Please contact the site owner for further assistance.</Trans>
</p>
</div>
);
}
return (
<div className="bg-background mx-auto w-full max-w-lg md:my-12 md:rounded-2xl md:border md:p-8 md:shadow-lg">
<h2 className="text-foreground mb-1 text-lg font-semibold">
<Trans>Sign Documents</Trans>
</h2>
<p className="text-muted-foreground text-sm">
<Trans>
You have been requested to sign the following documents. Review each document carefully
and complete the signing process.
</Trans>
</p>
{/* Progress Section */}
<div className="mt-6">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground font-medium">
<Trans>Progress</Trans>
</span>
<span className="text-muted-foreground">
{completedDocuments.length} of {totalDocuments} completed
</span>
</div>
<div className="mt-4">
<Progress value={progressPercentage} className="h-2" />
</div>
</div>
<div className="mt-6 flex flex-col gap-4">
{envelopes.map((envelope) => (
<div
key={envelope.document.id}
className="border-border flex items-center gap-4 rounded-lg border px-4 py-2"
>
<span className="text-foreground flex-1 truncate text-sm font-medium">
{envelope.document.title}
</span>
{match(envelope.recipient)
.with({ signingStatus: SigningStatus.SIGNED }, () => (
<Badge size="small" variant="default">
<Trans>Completed</Trans>
</Badge>
))
.with({ signingStatus: SigningStatus.REJECTED }, () => (
<Badge size="small" variant="destructive">
<Trans>Rejected</Trans>
</Badge>
))
.with({ readStatus: ReadStatus.OPENED }, () => (
<Badge size="small" variant="neutral">
<Trans>Viewed</Trans>
</Badge>
))
.otherwise(() => null)}
<Button
className="-mr-2"
variant="outline"
size="sm"
onClick={() => handleView(envelope.document)}
>
<EyeIcon className="mr-1 h-4 w-4" />
<Trans>View</Trans>
</Button>
</div>
))}
</div>
{/* Next Document Button */}
{!allDocumentsCompleted && nextDocumentToSign && (
<div className="mt-6">
<Button onClick={handleNextDocument} className="w-full" size="lg">
<Trans>View next document</Trans>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
{allDocumentsCompleted && (
<Alert className="mt-6 text-center">
<AlertTitle>
<Trans>All documents have been completed!</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Thank you for completing the signing process.</Trans>
</AlertDescription>
</Alert>
)}
</div>
);
}

View File

@ -0,0 +1,394 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { Loader, LucideChevronDown, LucideChevronUp, X } from 'lucide-react';
import { P, match } from 'ts-pattern';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
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 PDFViewer from '@documenso/ui/primitives/pdf-viewer';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
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) => field.type === FieldType.SIGNATURE);
const [pendingFields, completedFields] = [
document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ??
[],
document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ??
[],
];
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: 'Error',
description: '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="bg-background min-h-screen overflow-hidden">
<div id="document-field-portal-root" className="relative h-full w-full overflow-y-auto 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="text-primary h-8 w-8 animate-spin" />
<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="text-2xl font-semibold">{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 mb-4 mt-8 flex w-full flex-row-reverse items-baseline justify-between">
<DocumentSigningRejectDialog
document={document}
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,
})}
>
<PDFViewer
documentData={document.documentData}
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:bottom-[unset] md:top-0 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
{/* Header */}
<div className="embed--DocumentWidgetHeader">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
<Trans>Sign document</Trans>
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
</div>
<div className="embed--DocumentWidgetContent hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
</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="bg-background mt-2"
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="bg-background mt-2"
value={email}
disabled
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
disableAnimation
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">
{pendingFields.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>
)}
</div>
{hasDocumentLoaded && (
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{showPendingFieldTooltip && pendingFields.length > 0 && (
<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}
/>
)}
</>
))
.otherwise(() => null)}
</div>
</div>
);
};

View File

@ -0,0 +1,327 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { SigningStatus } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { isCommunityPlan as isUserCommunityPlan } from '@documenso/ee/server-only/util/is-community-plan';
import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise';
import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { BrandingLogo } from '~/components/general/branding-logo';
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
import { DocumentSigningRecipientProvider } from '~/components/general/document-signing/document-signing-recipient-provider';
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
import { injectCss } from '~/utils/css-vars';
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
import { MultiSignDocumentList } from '../../../../components/embed/multisign/multi-sign-document-list';
import { MultiSignDocumentSigningView } from '../../../../components/embed/multisign/multi-sign-document-signing-view';
import type { Route } from './+types/_index';
export async function loader({ request }: Route.LoaderArgs) {
const { user } = await getOptionalSession(request);
const url = new URL(request.url);
const tokens = url.searchParams.getAll('token');
const envelopes = await Promise.all(
tokens.map(async (token) => {
const document = await getDocumentAndSenderByToken({
token,
});
const recipient = await getRecipientByToken({ token });
console.log('document', document.id);
return { document, recipient };
}),
);
// Check the first envelope for whitelabelling settings (assuming all docs are from same team)
const firstDocument = envelopes[0]?.document;
if (!firstDocument) {
return superLoaderJson({
envelopes,
user,
hidePoweredBy: false,
allowWhitelabelling: false,
});
}
const team = firstDocument.teamId
? await getTeamById({ teamId: firstDocument.teamId, userId: firstDocument.userId }).catch(
() => null,
)
: null;
const [isPlatformDocument, isEnterpriseDocument, isCommunityPlan] = await Promise.all([
isDocumentPlatform(firstDocument),
isUserEnterprise({
userId: firstDocument.userId,
teamId: firstDocument.teamId ?? undefined,
}),
isUserCommunityPlan({
userId: firstDocument.userId,
teamId: firstDocument.teamId ?? undefined,
}),
]);
const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false;
const allowWhitelabelling = isCommunityPlan || isPlatformDocument || isEnterpriseDocument;
return superLoaderJson({
envelopes,
user,
hidePoweredBy,
allowWhitelabelling,
});
}
export default function MultisignPage() {
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
useSuperLoaderData<typeof loader>();
const revalidator = useRevalidator();
const [selectedDocument, setSelectedDocument] = useState<
(typeof envelopes)[number]['document'] | null
>(null);
// Additional state for embed functionality
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [isNameLocked, setIsNameLocked] = useState(false);
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
const [showOtherRecipientsCompletedFields, setShowOtherRecipientsCompletedFields] =
useState(false);
const [embedFullName, setEmbedFullName] = useState('');
// Check if all documents are completed
const isCompleted = envelopes.every(
(envelope) => envelope.recipient.signingStatus === SigningStatus.SIGNED,
);
const selectedRecipient = selectedDocument
? envelopes.find((e) => e.document.id === selectedDocument.id)?.recipient
: null;
const onSelectDocument = (document: (typeof envelopes)[number]['document']) => {
setSelectedDocument(document);
};
const onBackToDocumentList = () => {
setSelectedDocument(null);
// Revalidate to fetch fresh data when returning to document list
void revalidator.revalidate();
};
const onDocumentCompleted = (data: {
token: string;
documentId: number;
recipientId: number;
}) => {
// Send postMessage for individual document completion
if (window.parent) {
window.parent.postMessage(
{
action: 'document-completed',
data: {
token: data.token,
documentId: data.documentId,
recipientId: data.recipientId,
},
},
'*',
);
}
};
const onDocumentRejected = (data: {
token: string;
documentId: number;
recipientId: number;
reason: string;
}) => {
// Send postMessage for document rejection
if (window.parent) {
window.parent.postMessage(
{
action: 'document-rejected',
data: {
token: data.token,
documentId: data.documentId,
recipientId: data.recipientId,
reason: data.reason,
},
},
'*',
);
}
};
const onDocumentError = () => {
// Send postMessage for document error
if (window.parent) {
window.parent.postMessage(
{
action: 'document-error',
data: null,
},
'*',
);
}
};
const onDocumentReady = () => {
// Send postMessage when document is ready
if (window.parent) {
window.parent.postMessage(
{
action: 'document-ready',
data: null,
},
'*',
);
}
};
const onAllDocumentsCompleted = () => {
// Send postMessage for all documents completion
if (window.parent) {
window.parent.postMessage(
{
action: 'all-documents-completed',
data: {
documents: envelopes.map((envelope) => ({
token: envelope.recipient.token,
documentId: envelope.document.id,
recipientId: envelope.recipient.id,
action:
envelope.recipient.signingStatus === SigningStatus.SIGNED
? 'document-completed'
: 'document-rejected',
reason:
envelope.recipient.signingStatus === SigningStatus.REJECTED
? envelope.recipient.rejectionReason
: undefined,
})),
},
},
'*',
);
}
};
useEffect(() => {
if (
envelopes.every((envelope) => envelope.recipient.signingStatus !== SigningStatus.NOT_SIGNED)
) {
onAllDocumentsCompleted();
}
}, [envelopes]);
useLayoutEffect(() => {
const hash = window.location.hash.slice(1);
try {
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
if (!isCompleted && data.name) {
setEmbedFullName(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);
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,
});
}
} catch (err) {
console.error(err);
}
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
}, []);
// If a document is selected, show the signing view
if (selectedDocument && selectedRecipient) {
// Determine the full name to use - prioritize embed data, then user name, then recipient name
const fullNameToUse =
embedFullName ||
(user?.email === selectedRecipient.email ? user?.name : selectedRecipient.name) ||
'';
return (
<div className="p-4">
<DocumentSigningProvider
email={selectedRecipient.email}
fullName={fullNameToUse}
signature={user?.email === selectedRecipient.email ? user?.signature : undefined}
typedSignatureEnabled={selectedDocument.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={selectedDocument.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={selectedDocument.documentMeta?.drawSignatureEnabled}
>
<DocumentSigningAuthProvider
documentAuthOptions={selectedDocument.authOptions}
recipient={selectedRecipient}
user={user}
>
<DocumentSigningRecipientProvider recipient={selectedRecipient} targetSigner={null}>
<MultiSignDocumentSigningView
token={selectedRecipient.token}
recipientId={selectedRecipient.id}
onBack={onBackToDocumentList}
onDocumentCompleted={onDocumentCompleted}
onDocumentRejected={onDocumentRejected}
onDocumentError={onDocumentError}
onDocumentReady={onDocumentReady}
isNameLocked={isNameLocked}
/>
</DocumentSigningRecipientProvider>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
);
}
// Otherwise, show the document list
return (
<div className="p-4">
<MultiSignDocumentList envelopes={envelopes} onDocumentSelect={onSelectDocument} />
{!hidePoweredBy && (
<div className="bg-primary text-primary-foreground fixed bottom-0 left-0 z-40 rounded-tr px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100">
<span>Powered by</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,17 @@
import { z } from 'zod';
import { ZBaseEmbedDataSchema } from './embed-base-schemas';
export const ZEmbedMultiSignDocumentSchema = ZBaseEmbedDataSchema.extend({
email: z
.union([z.literal(''), z.string().email()])
.optional()
.transform((value) => value || undefined),
lockEmail: z.boolean().optional().default(false),
name: z
.string()
.optional()
.transform((value) => value || undefined),
lockName: z.boolean().optional().default(false),
allowDocumentRejection: z.boolean().optional(),
});

View File

@ -0,0 +1,75 @@
import { useEffect, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => {
const [bounds, setBounds] = useState({
top: 0,
left: 0,
height: 0,
width: 0,
});
const calculateBounds = () => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
throw new Error('Element not found');
}
if (withScroll) {
return getBoundingClientRect($el);
}
const { top, left, width, height } = $el.getBoundingClientRect();
return {
top,
left,
width,
height,
};
};
useEffect(() => {
setBounds(calculateBounds());
}, [calculateBounds]);
useEffect(() => {
const onResize = () => {
setBounds(calculateBounds());
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateBounds]);
useEffect(() => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
return;
}
const observer = new ResizeObserver(() => {
setBounds(calculateBounds());
});
observer.observe($el);
return () => {
observer.disconnect();
};
}, [calculateBounds]);
return bounds;
};

View File

@ -1,7 +1,9 @@
import { router } from '../trpc';
import { applyMultiSignSignatureRoute } from './apply-multi-sign-signature';
import { createEmbeddingDocumentRoute } from './create-embedding-document';
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
import { createEmbeddingTemplateRoute } from './create-embedding-template';
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
@ -13,4 +15,6 @@ export const embeddingPresignRouter = router({
createEmbeddingTemplate: createEmbeddingTemplateRoute,
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
applyMultiSignSignature: applyMultiSignSignatureRoute,
getMultiSignDocument: getMultiSignDocumentRoute,
});

View File

@ -0,0 +1,102 @@
import { FieldType, ReadStatus, SigningStatus } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { signFieldWithToken } from '@documenso/lib/server-only/field/sign-field-with-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { prisma } from '@documenso/prisma';
import { procedure } from '../trpc';
import {
ZApplyMultiSignSignatureRequestSchema,
ZApplyMultiSignSignatureResponseSchema,
} from './apply-multi-sign-signature.types';
export const applyMultiSignSignatureRoute = procedure
.input(ZApplyMultiSignSignatureRequestSchema)
.output(ZApplyMultiSignSignatureResponseSchema)
.mutation(async ({ input, ctx: { metadata } }) => {
try {
const { tokens, signature, isBase64 } = input;
// Get all documents and recipients for the tokens
const envelopes = await Promise.all(
tokens.map(async (token) => {
const document = await getDocumentByToken({ token });
const recipient = await getRecipientByToken({ token });
return { document, recipient };
}),
);
// Check if all documents have been viewed
const hasUnviewedDocuments = envelopes.some(
(envelope) => envelope.recipient.readStatus !== ReadStatus.OPENED,
);
if (hasUnviewedDocuments) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'All documents must be viewed before signing',
});
}
// If we require action auth we should abort here for now
for (const envelope of envelopes) {
const derivedRecipientActionAuth = extractDocumentAuthMethods({
documentAuth: envelope.document.authOptions,
recipientAuth: envelope.recipient.authOptions,
});
if (
derivedRecipientActionAuth.recipientAccessAuthRequired ||
derivedRecipientActionAuth.recipientActionAuthRequired
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message:
'Documents that require additional authentication cannot be multi signed at the moment',
});
}
}
// Sign all signature fields for each document
await Promise.all(
envelopes.map(async (envelope) => {
if (envelope.recipient.signingStatus === SigningStatus.REJECTED) {
return;
}
const signatureFields = await prisma.field.findMany({
where: {
documentId: envelope.document.id,
recipientId: envelope.recipient.id,
type: FieldType.SIGNATURE,
inserted: false,
},
});
await Promise.all(
signatureFields.map(async (field) =>
signFieldWithToken({
token: envelope.recipient.token,
fieldId: field.id,
value: signature,
isBase64,
requestMetadata: metadata.requestMetadata,
}),
),
);
}),
);
return { success: true };
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to apply multi-sign signature',
});
}
});

View File

@ -0,0 +1,18 @@
import { z } from 'zod';
export const ZApplyMultiSignSignatureRequestSchema = z.object({
tokens: z.array(z.string()).min(1, { message: 'At least one token is required' }),
signature: z.string().min(1, { message: 'Signature is required' }),
isBase64: z.boolean().optional().default(false),
});
export const ZApplyMultiSignSignatureResponseSchema = z.object({
success: z.boolean(),
});
export type TApplyMultiSignSignatureRequestSchema = z.infer<
typeof ZApplyMultiSignSignatureRequestSchema
>;
export type TApplyMultiSignSignatureResponseSchema = z.infer<
typeof ZApplyMultiSignSignatureResponseSchema
>;

View File

@ -0,0 +1,62 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document';
import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token';
import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token';
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
import { procedure } from '../trpc';
import {
ZGetMultiSignDocumentRequestSchema,
ZGetMultiSignDocumentResponseSchema,
} from './get-multi-sign-document.types';
export const getMultiSignDocumentRoute = procedure
.input(ZGetMultiSignDocumentRequestSchema)
.output(ZGetMultiSignDocumentResponseSchema)
.query(async ({ input, ctx: { metadata } }) => {
try {
const { token } = input;
const [document, fields, recipient] = await Promise.all([
getDocumentAndSenderByToken({
token,
requireAccessAuth: false,
}).catch(() => null),
getFieldsForToken({ token }),
getRecipientByToken({ token }).catch(() => null),
getCompletedFieldsForToken({ token }).catch(() => []),
]);
if (!document || !recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Document or recipient not found',
});
}
await viewedDocument({
token,
requestMetadata: metadata.requestMetadata,
});
// Transform fields to match our schema
const transformedFields = fields.map((field) => ({
...field,
recipient,
}));
return {
...document,
folder: null,
fields: transformedFields,
};
} catch (error) {
if (error instanceof AppError) {
throw error;
}
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
message: 'Failed to get document details',
});
}
});

View File

@ -0,0 +1,50 @@
import { z } from 'zod';
import { ZDocumentLiteSchema } from '@documenso/lib/types/document';
import { ZRecipientLiteSchema } from '@documenso/lib/types/recipient';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import FieldSchema from '@documenso/prisma/generated/zod/modelSchema/FieldSchema';
import SignatureSchema from '@documenso/prisma/generated/zod/modelSchema/SignatureSchema';
export const ZGetMultiSignDocumentRequestSchema = z.object({
token: z.string().min(1, { message: 'Token is required' }),
});
export const ZGetMultiSignDocumentResponseSchema = ZDocumentLiteSchema.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
documentMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
id: true,
subject: true,
message: true,
timezone: true,
password: true,
dateFormat: true,
documentId: true,
redirectUrl: true,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
drawSignatureEnabled: true,
allowDictateNextSigner: true,
language: true,
emailSettings: true,
}).nullable(),
fields: z.array(
FieldSchema.extend({
recipient: ZRecipientLiteSchema,
signature: SignatureSchema.nullable(),
}),
),
});
export type TGetMultiSignDocumentRequestSchema = z.infer<typeof ZGetMultiSignDocumentRequestSchema>;
export type TGetMultiSignDocumentResponseSchema = z.infer<
typeof ZGetMultiSignDocumentResponseSchema
>;

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { type Field, FieldType } from '@prisma/client';
import { createPortal } from 'react-dom';
@ -20,24 +20,37 @@ export function FieldContainerPortal({
children,
className = '',
}: FieldContainerPortalProps) {
const alternativePortalRoot = document.getElementById('document-field-portal-root');
const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
const style = useMemo(() => {
const portalBounds = alternativePortalRoot?.getBoundingClientRect();
const bounds = {
top: `${coords.y}px`,
left: `${coords.x}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
if (portalBounds) {
bounds.top = `${coords.y - portalBounds.top}px`;
bounds.left = `${coords.x - portalBounds.left}px`;
}
return bounds;
}, [coords, isCheckboxOrRadioField]);
return createPortal(
<div className={cn('absolute', className)} style={style}>
{children}
</div>,
document.body,
alternativePortalRoot ?? document.body,
);
}

View File

@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm', className)} {...props} />
<div ref={ref} className={cn('mt-2 text-sm', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';