mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 17:21:41 +10:00
Merge branch 'main' into feat/document-table-filters
This commit is contained in:
@ -332,7 +332,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
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}
|
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">
|
<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">
|
||||||
|
|||||||
@ -290,7 +290,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
{/* Widget */}
|
{/* Widget */}
|
||||||
<div
|
<div
|
||||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
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}
|
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">
|
<div className="embed--DocumentWidget border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
327
apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx
Normal file
327
apps/remix/app/routes/embed+/v1+/multisign+/_index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
apps/remix/app/types/embed-multisign-document-schema.ts
Normal file
17
apps/remix/app/types/embed-multisign-document-schema.ts
Normal 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(),
|
||||||
|
});
|
||||||
@ -40,43 +40,6 @@ services:
|
|||||||
entrypoint: sh
|
entrypoint: sh
|
||||||
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
command: -c 'mkdir -p /data/documenso && minio server /data --console-address ":9001" --address ":9002"'
|
||||||
|
|
||||||
triggerdotdev:
|
|
||||||
image: ghcr.io/triggerdotdev/trigger.dev:latest
|
|
||||||
container_name: triggerdotdev
|
|
||||||
environment:
|
|
||||||
- LOGIN_ORIGIN=http://localhost:3030
|
|
||||||
- APP_ORIGIN=http://localhost:3030
|
|
||||||
- PORT=3030
|
|
||||||
- REMIX_APP_PORT=3030
|
|
||||||
- MAGIC_LINK_SECRET=secret
|
|
||||||
- SESSION_SECRET=secret
|
|
||||||
- ENCRYPTION_KEY=deadbeefcafefeed
|
|
||||||
- DATABASE_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
|
|
||||||
- DIRECT_URL=postgresql://trigger:password@triggerdotdev_database:5432/trigger
|
|
||||||
- RUNTIME_PLATFORM=docker-compose
|
|
||||||
ports:
|
|
||||||
- 3030:3030
|
|
||||||
depends_on:
|
|
||||||
- triggerdotdev_database
|
|
||||||
|
|
||||||
triggerdotdev_database:
|
|
||||||
container_name: triggerdotdev_database
|
|
||||||
image: postgres:15
|
|
||||||
volumes:
|
|
||||||
- triggerdotdev_database:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=trigger
|
|
||||||
- POSTGRES_PASSWORD=password
|
|
||||||
- POSTGRES_DB=trigger
|
|
||||||
ports:
|
|
||||||
- 54321:5432
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
minio:
|
minio:
|
||||||
documenso_database:
|
documenso_database:
|
||||||
triggerdotdev_database:
|
|
||||||
|
|||||||
75
packages/lib/client-only/hooks/use-element-bounds.ts
Normal file
75
packages/lib/client-only/hooks/use-element-bounds.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -128,7 +128,7 @@ export const sealDocument = async ({
|
|||||||
|
|
||||||
// Normalize and flatten layers that could cause issues with the signature
|
// Normalize and flatten layers that could cause issues with the signature
|
||||||
normalizeSignatureAppearances(doc);
|
normalizeSignatureAppearances(doc);
|
||||||
flattenForm(doc);
|
await flattenForm(doc);
|
||||||
flattenAnnotations(doc);
|
flattenAnnotations(doc);
|
||||||
|
|
||||||
// Add rejection stamp if the document is rejected
|
// Add rejection stamp if the document is rejected
|
||||||
@ -153,7 +153,7 @@ export const sealDocument = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-flatten post-insertion to handle fields that create arcoFields
|
// Re-flatten post-insertion to handle fields that create arcoFields
|
||||||
flattenForm(doc);
|
await flattenForm(doc);
|
||||||
|
|
||||||
const pdfBytes = await doc.save();
|
const pdfBytes = await doc.save();
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
|
import type { PDFField, PDFWidgetAnnotation } from 'pdf-lib';
|
||||||
import {
|
import {
|
||||||
PDFCheckBox,
|
PDFCheckBox,
|
||||||
@ -13,6 +14,8 @@ import {
|
|||||||
translate,
|
translate,
|
||||||
} from 'pdf-lib';
|
} from 'pdf-lib';
|
||||||
|
|
||||||
|
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
|
||||||
|
|
||||||
export const removeOptionalContentGroups = (document: PDFDocument) => {
|
export const removeOptionalContentGroups = (document: PDFDocument) => {
|
||||||
const context = document.context;
|
const context = document.context;
|
||||||
const catalog = context.lookup(context.trailerInfo.Root);
|
const catalog = context.lookup(context.trailerInfo.Root);
|
||||||
@ -21,12 +24,20 @@ export const removeOptionalContentGroups = (document: PDFDocument) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const flattenForm = (document: PDFDocument) => {
|
export const flattenForm = async (document: PDFDocument) => {
|
||||||
removeOptionalContentGroups(document);
|
removeOptionalContentGroups(document);
|
||||||
|
|
||||||
const form = document.getForm();
|
const form = document.getForm();
|
||||||
|
|
||||||
form.updateFieldAppearances();
|
const fontNoto = await fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/fonts/noto-sans.ttf`).then(
|
||||||
|
async (res) => res.arrayBuffer(),
|
||||||
|
);
|
||||||
|
|
||||||
|
document.registerFontkit(fontkit);
|
||||||
|
|
||||||
|
const font = await document.embedFont(fontNoto);
|
||||||
|
|
||||||
|
form.updateFieldAppearances(font);
|
||||||
|
|
||||||
for (const field of form.getFields()) {
|
for (const field of form.getFields()) {
|
||||||
for (const widget of field.acroField.getWidgets()) {
|
for (const widget of field.acroField.getWidgets()) {
|
||||||
|
|||||||
@ -1,8 +1,15 @@
|
|||||||
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
// https://github.com/Hopding/pdf-lib/issues/20#issuecomment-412852821
|
||||||
import fontkit from '@pdf-lib/fontkit';
|
import fontkit from '@pdf-lib/fontkit';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import type { PDFDocument, PDFFont } from 'pdf-lib';
|
import type { PDFDocument, PDFFont, PDFTextField } from 'pdf-lib';
|
||||||
import { RotationTypes, TextAlignment, degrees, radiansToDegrees, rgb } from 'pdf-lib';
|
import {
|
||||||
|
RotationTypes,
|
||||||
|
TextAlignment,
|
||||||
|
degrees,
|
||||||
|
radiansToDegrees,
|
||||||
|
rgb,
|
||||||
|
setFontAndSize,
|
||||||
|
} from 'pdf-lib';
|
||||||
import { P, match } from 'ts-pattern';
|
import { P, match } from 'ts-pattern';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -442,6 +449,10 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
adjustedFieldY = adjustedPosition.yPos;
|
adjustedFieldY = adjustedPosition.yPos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set properties for the text field
|
||||||
|
setTextFieldFontSize(textField, font, fontSize);
|
||||||
|
textField.setText(textToInsert);
|
||||||
|
|
||||||
// Set the position and size of the text field
|
// Set the position and size of the text field
|
||||||
textField.addToPage(page, {
|
textField.addToPage(page, {
|
||||||
x: adjustedFieldX,
|
x: adjustedFieldX,
|
||||||
@ -450,6 +461,8 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
height: adjustedFieldHeight,
|
height: adjustedFieldHeight,
|
||||||
rotate: degrees(pageRotationInDegrees),
|
rotate: degrees(pageRotationInDegrees),
|
||||||
|
|
||||||
|
font,
|
||||||
|
|
||||||
// Hide borders.
|
// Hide borders.
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
borderColor: undefined,
|
borderColor: undefined,
|
||||||
@ -457,10 +470,6 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
|
|||||||
|
|
||||||
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
|
...(isDebugMode ? { borderWidth: 1, borderColor: rgb(0, 0, 1) } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set properties for the text field
|
|
||||||
textField.setFontSize(fontSize);
|
|
||||||
textField.setText(textToInsert);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return pdf;
|
return pdf;
|
||||||
@ -629,3 +638,21 @@ function breakLongString(text: string, maxWidth: number, font: PDFFont, fontSize
|
|||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setTextFieldFontSize = (textField: PDFTextField, font: PDFFont, fontSize: number) => {
|
||||||
|
textField.defaultUpdateAppearances(font);
|
||||||
|
textField.updateAppearances(font);
|
||||||
|
|
||||||
|
try {
|
||||||
|
textField.setFontSize(fontSize);
|
||||||
|
} catch (err) {
|
||||||
|
let da = textField.acroField.getDefaultAppearance() ?? '';
|
||||||
|
|
||||||
|
da += `\n ${setFontAndSize(font.name, fontSize)}`;
|
||||||
|
|
||||||
|
textField.acroField.setDefaultAppearance(da);
|
||||||
|
}
|
||||||
|
|
||||||
|
textField.defaultUpdateAppearances(font);
|
||||||
|
textField.updateAppearances(font);
|
||||||
|
};
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { router } from '../trpc';
|
import { router } from '../trpc';
|
||||||
|
import { applyMultiSignSignatureRoute } from './apply-multi-sign-signature';
|
||||||
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
import { createEmbeddingDocumentRoute } from './create-embedding-document';
|
||||||
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
import { createEmbeddingPresignTokenRoute } from './create-embedding-presign-token';
|
||||||
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
import { createEmbeddingTemplateRoute } from './create-embedding-template';
|
||||||
|
import { getMultiSignDocumentRoute } from './get-multi-sign-document';
|
||||||
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
|
import { updateEmbeddingDocumentRoute } from './update-embedding-document';
|
||||||
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
|
import { updateEmbeddingTemplateRoute } from './update-embedding-template';
|
||||||
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
|
import { verifyEmbeddingPresignTokenRoute } from './verify-embedding-presign-token';
|
||||||
@ -13,4 +15,6 @@ export const embeddingPresignRouter = router({
|
|||||||
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
createEmbeddingTemplate: createEmbeddingTemplateRoute,
|
||||||
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
|
updateEmbeddingDocument: updateEmbeddingDocumentRoute,
|
||||||
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
|
updateEmbeddingTemplate: updateEmbeddingTemplateRoute,
|
||||||
|
applyMultiSignSignature: applyMultiSignSignatureRoute,
|
||||||
|
getMultiSignDocument: getMultiSignDocumentRoute,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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
|
||||||
|
>;
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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
|
||||||
|
>;
|
||||||
@ -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 { type Field, FieldType } from '@prisma/client';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
@ -20,11 +20,16 @@ export function FieldContainerPortal({
|
|||||||
children,
|
children,
|
||||||
className = '',
|
className = '',
|
||||||
}: FieldContainerPortalProps) {
|
}: FieldContainerPortalProps) {
|
||||||
|
const alternativePortalRoot = document.getElementById('document-field-portal-root');
|
||||||
|
|
||||||
const coords = useFieldPageCoords(field);
|
const coords = useFieldPageCoords(field);
|
||||||
|
|
||||||
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
|
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
|
||||||
|
|
||||||
const style = {
|
const style = useMemo(() => {
|
||||||
|
const portalBounds = alternativePortalRoot?.getBoundingClientRect();
|
||||||
|
|
||||||
|
const bounds = {
|
||||||
top: `${coords.y}px`,
|
top: `${coords.y}px`,
|
||||||
left: `${coords.x}px`,
|
left: `${coords.x}px`,
|
||||||
...(!isCheckboxOrRadioField && {
|
...(!isCheckboxOrRadioField && {
|
||||||
@ -33,11 +38,19 @@ export function FieldContainerPortal({
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (portalBounds) {
|
||||||
|
bounds.top = `${coords.y - portalBounds.top}px`;
|
||||||
|
bounds.left = `${coords.x - portalBounds.left}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bounds;
|
||||||
|
}, [coords, isCheckboxOrRadioField]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className={cn('absolute', className)} style={style}>
|
<div className={cn('absolute', className)} style={style}>
|
||||||
{children}
|
{children}
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
alternativePortalRoot ?? document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -58,7 +58,7 @@ const AlertDescription = React.forwardRef<
|
|||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ 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';
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|||||||
Reference in New Issue
Block a user