Compare commits
26 Commits
717fa8f870
...
feat/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
| c615e30633 | |||
| 358ba2dd6f | |||
| 7fa86fe297 | |||
| 0b91d33bfb | |||
| e010238bcc | |||
| 88371b665a | |||
| 1650c55b19 | |||
| 60d73e0921 | |||
| 4a779ec81e | |||
| 7f19ec1265 | |||
| d6a2f5a4c9 | |||
| d05bfa9fed | |||
| 498a2be1c7 | |||
| 3e84aa632f | |||
| a08a77e98b | |||
| 13d9ca7a0e | |||
| d25565b7d0 | |||
| 91421a7d62 | |||
| a9f1e39b10 | |||
| b37748654e | |||
| b3ed80d721 | |||
| b3cb750470 | |||
| 1e52493144 | |||
| ab95e80987 | |||
| 1780a5c262 | |||
| cb9bf407f7 |
@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
type DocumentDuplicateDialogProps = {
|
type DocumentDuplicateDialogProps = {
|
||||||
id: number;
|
id: string;
|
||||||
|
token?: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (_open: boolean) => void;
|
onOpenChange: (_open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentDuplicateDialog = ({
|
export const DocumentDuplicateDialog = ({
|
||||||
id,
|
id,
|
||||||
|
token,
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentDuplicateDialogProps) => {
|
}: DocumentDuplicateDialogProps) => {
|
||||||
@ -36,27 +38,23 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { data: document, isLoading } = trpcReact.document.get.useQuery(
|
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||||
{
|
trpcReact.envelope.item.getManyByToken.useQuery(
|
||||||
documentId: id,
|
{
|
||||||
},
|
envelopeId: id,
|
||||||
{
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
queryHash: `document-duplicate-dialog-${id}`,
|
},
|
||||||
enabled: open === true,
|
{
|
||||||
},
|
enabled: open,
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const documentData = document?.documentData
|
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||||
? {
|
|
||||||
...document.documentData,
|
|
||||||
data: document.documentData.initialData,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
|
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||||
trpcReact.document.duplicate.useMutation({
|
trpcReact.envelope.duplicate.useMutation({
|
||||||
onSuccess: async ({ id }) => {
|
onSuccess: async ({ id }) => {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Document Duplicated`),
|
title: _(msg`Document Duplicated`),
|
||||||
@ -71,7 +69,7 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
const onDuplicate = async () => {
|
const onDuplicate = async () => {
|
||||||
try {
|
try {
|
||||||
await duplicateDocument({ documentId: id });
|
await duplicateEnvelope({ envelopeId: id });
|
||||||
} catch {
|
} catch {
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Something went wrong`),
|
title: _(msg`Something went wrong`),
|
||||||
@ -83,14 +81,14 @@ export const DocumentDuplicateDialog = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
|
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<Trans>Duplicate</Trans>
|
<Trans>Duplicate</Trans>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{!documentData || isLoading ? (
|
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
|
||||||
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
|
||||||
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
|
||||||
<Trans>Loading Document...</Trans>
|
<Trans>Loading Document...</Trans>
|
||||||
@ -98,7 +96,12 @@ export const DocumentDuplicateDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
|
||||||
<PDFViewer key={document?.id} documentData={documentData} />
|
<PDFViewer
|
||||||
|
key={envelopeItems[0].id}
|
||||||
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
|
version="original"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -115,8 +118,8 @@ export const DocumentDuplicateDialog = ({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isDuplicateLoading || isLoading}
|
disabled={isDuplicating}
|
||||||
loading={isDuplicateLoading}
|
loading={isDuplicating}
|
||||||
onClick={onDuplicate}
|
onClick={onDuplicate}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -2,11 +2,10 @@ import { useState } from 'react';
|
|||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
import { DownloadIcon, FileTextIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { downloadFile } from '@documenso/lib/client-only/download-file';
|
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
||||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
@ -20,9 +19,7 @@ import {
|
|||||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
|
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
|
||||||
documentData: DocumentData;
|
|
||||||
};
|
|
||||||
|
|
||||||
type EnvelopeDownloadDialogProps = {
|
type EnvelopeDownloadDialogProps = {
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
@ -64,12 +61,12 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
access: token ? { type: 'recipient', token } : { type: 'user' },
|
access: token ? { type: 'recipient', token } : { type: 'user' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
|
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
|
||||||
enabled: open,
|
enabled: open,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
|
const envelopeItems = envelopeItemsPayload?.data || [];
|
||||||
|
|
||||||
const onDownload = async (
|
const onDownload = async (
|
||||||
envelopeItem: EnvelopeItemToDownload,
|
envelopeItem: EnvelopeItemToDownload,
|
||||||
@ -87,19 +84,11 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const downloadUrl = token
|
await downloadPDF({
|
||||||
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${envelopeItemId}/download/${version}`
|
envelopeItem,
|
||||||
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
|
token,
|
||||||
|
fileName: envelopeItem.title,
|
||||||
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
version,
|
||||||
|
|
||||||
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
|
|
||||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
|
||||||
const filename = `${baseTitle}${suffix}`;
|
|
||||||
|
|
||||||
downloadFile({
|
|
||||||
filename,
|
|
||||||
data: blob,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setIsDownloadingState((prev) => ({
|
setIsDownloadingState((prev) => ({
|
||||||
@ -140,7 +129,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{isLoadingEnvelopeItems ? (
|
{isLoadingEnvelopeItems ? (
|
||||||
<>
|
<>
|
||||||
{Array.from({ length: 2 }).map((_, index) => (
|
{Array.from({ length: 1 }).map((_, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||||
@ -169,6 +158,7 @@ export const EnvelopeDownloadDialog = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Todo: Envelopes - Fix overflow */}
|
||||||
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
|
||||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||||
<Trans>PDF Document</Trans>
|
<Trans>PDF Document</Trans>
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
|
|||||||
|
|
||||||
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
|
||||||
trpc.envelope.duplicate.useMutation({
|
trpc.envelope.duplicate.useMutation({
|
||||||
onSuccess: async ({ duplicatedEnvelopeId }) => {
|
onSuccess: async ({ id }) => {
|
||||||
toast({
|
toast({
|
||||||
title: t`Envelope Duplicated`,
|
title: t`Envelope Duplicated`,
|
||||||
description: t`Your envelope has been successfully duplicated.`,
|
description: t`Your envelope has been successfully duplicated.`,
|
||||||
@ -55,7 +55,7 @@ export const EnvelopeDuplicateDialog = ({
|
|||||||
? formatDocumentsPath(team.url)
|
? formatDocumentsPath(team.url)
|
||||||
: formatTemplatesPath(team.url);
|
: formatTemplatesPath(team.url);
|
||||||
|
|
||||||
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
|
await navigate(`${path}/${id}/edit`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -185,6 +185,10 @@ export const OrganisationMemberInviteDialog = ({
|
|||||||
return 'form';
|
return 'form';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
|
||||||
|
return 'form';
|
||||||
|
}
|
||||||
|
|
||||||
// This is probably going to screw us over in the future.
|
// This is probably going to screw us over in the future.
|
||||||
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
|
||||||
return 'alert';
|
return 'alert';
|
||||||
|
|||||||
@ -143,7 +143,7 @@ export function TemplateUseDialog({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const envelopeItems = response?.envelopeItems ?? [];
|
const envelopeItems = response?.data ?? [];
|
||||||
|
|
||||||
const { mutateAsync: createDocumentFromTemplate } =
|
const { mutateAsync: createDocumentFromTemplate } =
|
||||||
trpc.template.createDocumentFromTemplate.useMutation();
|
trpc.template.createDocumentFromTemplate.useMutation();
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
|
|||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentData, FieldType } from '@prisma/client';
|
import type { DocumentData, FieldType } from '@prisma/client';
|
||||||
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
|
||||||
|
import { base64 } from '@scure/base';
|
||||||
import { ChevronsUpDown } from 'lucide-react';
|
import { ChevronsUpDown } from 'lucide-react';
|
||||||
import { useFieldArray, useForm } from 'react-hook-form';
|
import { useFieldArray, useForm } from 'react-hook-form';
|
||||||
import { useHotkeys } from 'react-hotkeys-hook';
|
import { useHotkeys } from 'react-hotkeys-hook';
|
||||||
@ -12,7 +13,6 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
|
|||||||
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
|
||||||
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
|
||||||
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
import { base64 } from '@documenso/lib/universal/base64';
|
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
|
||||||
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
|
||||||
@ -83,21 +83,14 @@ export const ConfigureFieldsView = ({
|
|||||||
|
|
||||||
const normalizedDocumentData = useMemo(() => {
|
const normalizedDocumentData = useMemo(() => {
|
||||||
if (documentData) {
|
if (documentData) {
|
||||||
return documentData;
|
return documentData.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!configData.documentData) {
|
if (!configData.documentData) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = base64.encode(configData.documentData?.data);
|
return base64.encode(configData.documentData.data);
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'preview',
|
|
||||||
type: 'BYTES_64',
|
|
||||||
data,
|
|
||||||
initialData: data,
|
|
||||||
} satisfies DocumentData;
|
|
||||||
}, [configData.documentData]);
|
}, [configData.documentData]);
|
||||||
|
|
||||||
const recipients = useMemo(() => {
|
const recipients = useMemo(() => {
|
||||||
@ -541,7 +534,15 @@ export const ConfigureFieldsView = ({
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
{normalizedDocumentData && (
|
{normalizedDocumentData && (
|
||||||
<div>
|
<div>
|
||||||
<PDFViewer documentData={normalizedDocumentData} />
|
<PDFViewer
|
||||||
|
overrideData={normalizedDocumentData}
|
||||||
|
envelopeItem={{
|
||||||
|
id: '',
|
||||||
|
envelopeId: '',
|
||||||
|
}}
|
||||||
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
|
|
||||||
<ElementVisible
|
<ElementVisible
|
||||||
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
|
|||||||
email?: string;
|
email?: string;
|
||||||
returnTo: string;
|
returnTo: string;
|
||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
oidcProviderLabel?: string;
|
oidcProviderLabel?: string;
|
||||||
};
|
};
|
||||||
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
email,
|
email,
|
||||||
returnTo,
|
returnTo,
|
||||||
// isGoogleSSOEnabled,
|
// isGoogleSSOEnabled,
|
||||||
|
// isMicrosoftSSOEnabled,
|
||||||
// isOIDCSSOEnabled,
|
// isOIDCSSOEnabled,
|
||||||
// oidcProviderLabel,
|
// oidcProviderLabel,
|
||||||
}: EmbedAuthenticationRequiredProps) => {
|
}: EmbedAuthenticationRequiredProps) => {
|
||||||
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
|
|||||||
<SignInForm
|
<SignInForm
|
||||||
// Embed currently not supported.
|
// Embed currently not supported.
|
||||||
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
// isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
// isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
// oidcProviderLabel={oidcProviderLabel}
|
// oidcProviderLabel={oidcProviderLabel}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
|
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
|
||||||
import { type DocumentData, type Field, FieldType } from '@prisma/client';
|
import { type Field, FieldType } from '@prisma/client';
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
@ -47,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
|
|||||||
token: string;
|
token: string;
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
documentData: DocumentData;
|
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||||
recipient: Recipient;
|
recipient: Recipient;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
metadata?: DocumentMeta | null;
|
metadata?: DocumentMeta | null;
|
||||||
@ -59,7 +59,7 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
token,
|
token,
|
||||||
envelopeId,
|
envelopeId,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
documentData,
|
envelopeItems,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
metadata,
|
metadata,
|
||||||
@ -335,7 +335,9 @@ export const EmbedDirectTemplateClientPage = ({
|
|||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,14 +3,8 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
|
|||||||
import { msg } from '@lingui/core/macro';
|
import { msg } from '@lingui/core/macro';
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentMeta } from '@prisma/client';
|
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
|
||||||
import {
|
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
type DocumentData,
|
|
||||||
type Field,
|
|
||||||
FieldType,
|
|
||||||
RecipientRole,
|
|
||||||
SigningStatus,
|
|
||||||
} from '@prisma/client';
|
|
||||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
|
||||||
@ -46,11 +40,11 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
|
|||||||
import { EmbedDocumentFields } from './embed-document-fields';
|
import { EmbedDocumentFields } from './embed-document-fields';
|
||||||
import { EmbedDocumentRejected } from './embed-document-rejected';
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||||
|
|
||||||
export type EmbedSignDocumentClientPageProps = {
|
export type EmbedSignDocumentV1ClientPageProps = {
|
||||||
token: string;
|
token: string;
|
||||||
documentId: number;
|
documentId: number;
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
documentData: DocumentData;
|
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
|
||||||
recipient: RecipientWithFields;
|
recipient: RecipientWithFields;
|
||||||
fields: Field[];
|
fields: Field[];
|
||||||
completedFields: DocumentField[];
|
completedFields: DocumentField[];
|
||||||
@ -61,11 +55,11 @@ export type EmbedSignDocumentClientPageProps = {
|
|||||||
allRecipients?: RecipientWithFields[];
|
allRecipients?: RecipientWithFields[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmbedSignDocumentClientPage = ({
|
export const EmbedSignDocumentV1ClientPage = ({
|
||||||
token,
|
token,
|
||||||
documentId,
|
documentId,
|
||||||
envelopeId,
|
envelopeId,
|
||||||
documentData,
|
envelopeItems,
|
||||||
recipient,
|
recipient,
|
||||||
fields,
|
fields,
|
||||||
completedFields,
|
completedFields,
|
||||||
@ -74,7 +68,7 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
hidePoweredBy = false,
|
hidePoweredBy = false,
|
||||||
allowWhitelabelling = false,
|
allowWhitelabelling = false,
|
||||||
allRecipients = [],
|
allRecipients = [],
|
||||||
}: EmbedSignDocumentClientPageProps) => {
|
}: EmbedSignDocumentV1ClientPageProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -293,7 +287,9 @@ export const EmbedSignDocumentClientPage = ({
|
|||||||
{/* Viewer */}
|
{/* Viewer */}
|
||||||
<div className="embed--DocumentViewer flex-1">
|
<div className="embed--DocumentViewer flex-1">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={documentData}
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
onDocumentLoad={() => setHasDocumentLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -0,0 +1,232 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLingui } from '@lingui/react';
|
||||||
|
|
||||||
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
|
|
||||||
|
import { ZSignDocumentEmbedDataSchema } from '~/types/embed-document-sign-schema';
|
||||||
|
import { injectCss } from '~/utils/css-vars';
|
||||||
|
|
||||||
|
import { DocumentSigningPageViewV2 } from '../general/document-signing/document-signing-page-view-v2';
|
||||||
|
import { useRequiredEnvelopeSigningContext } from '../general/document-signing/envelope-signing-provider';
|
||||||
|
import { EmbedClientLoading } from './embed-client-loading';
|
||||||
|
import { EmbedDocumentCompleted } from './embed-document-completed';
|
||||||
|
import { EmbedDocumentRejected } from './embed-document-rejected';
|
||||||
|
import { EmbedSigningProvider } from './embed-signing-context';
|
||||||
|
|
||||||
|
export type EmbedSignDocumentV2ClientPageProps = {
|
||||||
|
hidePoweredBy?: boolean;
|
||||||
|
allowWhitelabelling?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmbedSignDocumentV2ClientPage = ({
|
||||||
|
hidePoweredBy = false,
|
||||||
|
allowWhitelabelling = false,
|
||||||
|
}: EmbedSignDocumentV2ClientPageProps) => {
|
||||||
|
const { _ } = useLingui();
|
||||||
|
|
||||||
|
const { envelope, recipient, envelopeData, setFullName, fullName } =
|
||||||
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { isCompleted, isRejected, recipientSignature } = envelopeData;
|
||||||
|
|
||||||
|
// !: Not used at the moment, may be removed in the future.
|
||||||
|
// const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
|
||||||
|
const [hasFinishedInit, setHasFinishedInit] = useState(false);
|
||||||
|
const [allowDocumentRejection, setAllowDocumentRejection] = useState(false);
|
||||||
|
const [isNameLocked, setIsNameLocked] = useState(false);
|
||||||
|
|
||||||
|
const onDocumentCompleted = (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-completed',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentError = () => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-error',
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentReady = () => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-ready',
|
||||||
|
data: null,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldSigned = (data: { fieldId?: number; value?: string; isBase64?: boolean }) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'field-signed',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFieldUnsigned = (data: { fieldId?: number }) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'field-unsigned',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentRejected = (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => {
|
||||||
|
if (window.parent) {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{
|
||||||
|
action: 'document-rejected',
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = ZSignDocumentEmbedDataSchema.parse(JSON.parse(decodeURIComponent(atob(hash))));
|
||||||
|
|
||||||
|
if (!isCompleted && data.name) {
|
||||||
|
setFullName(data.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since a recipient can be provided a name we can lock it without requiring
|
||||||
|
// a to be provided by the parent application, unlike direct templates.
|
||||||
|
setIsNameLocked(!!data.lockName);
|
||||||
|
setAllowDocumentRejection(!!data.allowDocumentRejection);
|
||||||
|
|
||||||
|
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 setters are stable we still want to ensure we're avoiding
|
||||||
|
// !: re-renders.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allowWhitelabelling]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasFinishedInit) {
|
||||||
|
onDocumentReady();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [hasFinishedInit]);
|
||||||
|
|
||||||
|
// Listen for document completion events from the envelope signing context
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCompleted) {
|
||||||
|
onDocumentCompleted({
|
||||||
|
token: recipient.token,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isCompleted, envelope.id, recipient.id, recipient.token]);
|
||||||
|
|
||||||
|
// Listen for document rejection events
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRejected) {
|
||||||
|
onDocumentRejected({
|
||||||
|
token: recipient.token,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isRejected, envelope.id, recipient.id, recipient.token]);
|
||||||
|
|
||||||
|
if (isRejected) {
|
||||||
|
return <EmbedDocumentRejected />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
return (
|
||||||
|
<EmbedDocumentCompleted
|
||||||
|
name={fullName}
|
||||||
|
signature={
|
||||||
|
recipientSignature
|
||||||
|
? {
|
||||||
|
id: 1,
|
||||||
|
fieldId: 1,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
created: new Date(),
|
||||||
|
signatureImageAsBase64: recipientSignature.signatureImageAsBase64,
|
||||||
|
typedSignature: recipientSignature.typedSignature,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmbedSigningProvider
|
||||||
|
isNameLocked={isNameLocked}
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowDocumentRejection={allowDocumentRejection}
|
||||||
|
onDocumentCompleted={onDocumentCompleted}
|
||||||
|
onDocumentError={onDocumentError}
|
||||||
|
onDocumentRejected={onDocumentRejected}
|
||||||
|
onDocumentReady={onDocumentReady}
|
||||||
|
onFieldSigned={onFieldSigned}
|
||||||
|
onFieldUnsigned={onFieldUnsigned}
|
||||||
|
>
|
||||||
|
<div className="embed--Root relative">
|
||||||
|
{!hasFinishedInit && <EmbedClientLoading />}
|
||||||
|
|
||||||
|
<DocumentSigningPageViewV2 />
|
||||||
|
</div>
|
||||||
|
</EmbedSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
101
apps/remix/app/components/embed/embed-signing-context.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export type EmbedSigningContextValue = {
|
||||||
|
isEmbed: true;
|
||||||
|
allowDocumentRejection: boolean;
|
||||||
|
isNameLocked: boolean;
|
||||||
|
isEmailLocked: boolean;
|
||||||
|
hidePoweredBy: boolean;
|
||||||
|
onDocumentCompleted: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => void;
|
||||||
|
onDocumentError: () => void;
|
||||||
|
onDocumentRejected: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => void;
|
||||||
|
onDocumentReady: () => void;
|
||||||
|
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||||
|
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmbedSigningContext = createContext<EmbedSigningContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useEmbedSigningContext = () => {
|
||||||
|
return useContext(EmbedSigningContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRequiredEmbedSigningContext = () => {
|
||||||
|
const context = useEmbedSigningContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRequiredEmbedSigningContext must be used within EmbedSigningProvider');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmbedSigningProviderProps = {
|
||||||
|
allowDocumentRejection?: boolean;
|
||||||
|
isNameLocked?: boolean;
|
||||||
|
isEmailLocked?: boolean;
|
||||||
|
hidePoweredBy?: boolean;
|
||||||
|
onDocumentCompleted: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
}) => void;
|
||||||
|
onDocumentError: () => void;
|
||||||
|
onDocumentRejected: (data: {
|
||||||
|
token: string;
|
||||||
|
documentId: number;
|
||||||
|
envelopeId: string;
|
||||||
|
recipientId: number;
|
||||||
|
reason?: string;
|
||||||
|
}) => void;
|
||||||
|
onDocumentReady: () => void;
|
||||||
|
onFieldSigned: (data: { fieldId?: number; value?: string; isBase64?: boolean }) => void;
|
||||||
|
onFieldUnsigned: (data: { fieldId?: number }) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmbedSigningProvider = ({
|
||||||
|
allowDocumentRejection = false,
|
||||||
|
isNameLocked = false,
|
||||||
|
isEmailLocked = true,
|
||||||
|
hidePoweredBy = false,
|
||||||
|
onDocumentCompleted,
|
||||||
|
onDocumentError,
|
||||||
|
onDocumentRejected,
|
||||||
|
onDocumentReady,
|
||||||
|
onFieldSigned,
|
||||||
|
onFieldUnsigned,
|
||||||
|
children,
|
||||||
|
}: EmbedSigningProviderProps) => {
|
||||||
|
return (
|
||||||
|
<EmbedSigningContext.Provider
|
||||||
|
value={{
|
||||||
|
isEmbed: true,
|
||||||
|
allowDocumentRejection,
|
||||||
|
isNameLocked,
|
||||||
|
isEmailLocked,
|
||||||
|
hidePoweredBy,
|
||||||
|
onDocumentCompleted,
|
||||||
|
onDocumentError,
|
||||||
|
onDocumentRejected,
|
||||||
|
onDocumentReady,
|
||||||
|
onFieldSigned,
|
||||||
|
onFieldUnsigned,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EmbedSigningContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -226,7 +226,9 @@ export const MultiSignDocumentSigningView = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
documentData={document.documentData}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => {
|
onDocumentLoad={() => {
|
||||||
setHasDocumentLoaded(true);
|
setHasDocumentLoaded(true);
|
||||||
onDocumentReady?.();
|
onDocumentReady?.();
|
||||||
|
|||||||
@ -92,6 +92,7 @@ export const SignInForm = ({
|
|||||||
|
|
||||||
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||||
|
|
||||||
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
'totp' | 'backup'
|
'totp' | 'backup'
|
||||||
@ -317,6 +318,8 @@ export const SignInForm = ({
|
|||||||
if (email) {
|
if (email) {
|
||||||
form.setValue('email', email);
|
form.setValue('email', email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -383,56 +386,64 @@ export const SignInForm = ({
|
|||||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{hasSocialAuthEnabled && (
|
{!isEmbeddedRedirect && (
|
||||||
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
<>
|
||||||
<div className="bg-border h-px flex-1" />
|
{hasSocialAuthEnabled && (
|
||||||
<span className="text-muted-foreground bg-transparent">
|
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
|
||||||
<Trans>Or continue with</Trans>
|
<div className="bg-border h-px flex-1" />
|
||||||
</span>
|
<span className="text-muted-foreground bg-transparent">
|
||||||
<div className="bg-border h-px flex-1" />
|
<Trans>Or continue with</Trans>
|
||||||
</div>
|
</span>
|
||||||
)}
|
<div className="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isGoogleSSOEnabled && (
|
{isGoogleSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithGoogleClick}
|
onClick={onSignInWithGoogleClick}
|
||||||
>
|
>
|
||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isMicrosoftSSOEnabled && (
|
{isMicrosoftSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithMicrosoftClick}
|
onClick={onSignInWithMicrosoftClick}
|
||||||
>
|
>
|
||||||
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
|
<img
|
||||||
Microsoft
|
className="mr-2 h-4 w-4"
|
||||||
</Button>
|
alt="Microsoft Logo"
|
||||||
)}
|
src={'/static/microsoft.svg'}
|
||||||
|
/>
|
||||||
|
Microsoft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{isOIDCSSOEnabled && (
|
{isOIDCSSOEnabled && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="bg-background text-muted-foreground border"
|
className="bg-background text-muted-foreground border"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
onClick={onSignInWithOIDCClick}
|
onClick={onSignInWithOIDCClick}
|
||||||
>
|
>
|
||||||
<FaIdCardClip className="mr-2 h-5 w-5" />
|
<FaIdCardClip className="mr-2 h-5 w-5" />
|
||||||
{oidcProviderLabel || 'OIDC'}
|
{oidcProviderLabel || 'OIDC'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -68,6 +68,7 @@ export type SignUpFormProps = {
|
|||||||
isGoogleSSOEnabled?: boolean;
|
isGoogleSSOEnabled?: boolean;
|
||||||
isMicrosoftSSOEnabled?: boolean;
|
isMicrosoftSSOEnabled?: boolean;
|
||||||
isOIDCSSOEnabled?: boolean;
|
isOIDCSSOEnabled?: boolean;
|
||||||
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SignUpForm = ({
|
export const SignUpForm = ({
|
||||||
@ -76,6 +77,7 @@ export const SignUpForm = ({
|
|||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
|
returnTo,
|
||||||
}: SignUpFormProps) => {
|
}: SignUpFormProps) => {
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@ -110,7 +112,7 @@ export const SignUpForm = ({
|
|||||||
signature,
|
signature,
|
||||||
});
|
});
|
||||||
|
|
||||||
await navigate(`/unverified-account`);
|
await navigate(returnTo ? returnTo : '/unverified-account');
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: _(msg`Registration Successful`),
|
title: _(msg`Registration Successful`),
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { Theme, useTheme } from 'remix-themes';
|
import { Theme, useTheme } from 'remix-themes';
|
||||||
|
|
||||||
|
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
|
||||||
import {
|
import {
|
||||||
@ -63,10 +64,12 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [pages, setPages] = useState<string[]>([]);
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebouncedValue(search, 200);
|
||||||
|
|
||||||
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
|
||||||
trpcReact.document.search.useQuery(
|
trpcReact.document.search.useQuery(
|
||||||
{
|
{
|
||||||
query: search,
|
query: debouncedSearch,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: (previousData) => previousData,
|
placeholderData: (previousData) => previousData,
|
||||||
@ -232,6 +235,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
<Trans>No results found.</Trans>
|
<Trans>No results found.</Trans>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!currentPage && (
|
{!currentPage && (
|
||||||
<>
|
<>
|
||||||
{documentPageLinks.length > 0 && (
|
{documentPageLinks.length > 0 && (
|
||||||
@ -239,14 +243,17 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
<Commands push={push} pages={documentPageLinks} />
|
<Commands push={push} pages={documentPageLinks} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{templatePageLinks.length > 0 && (
|
{templatePageLinks.length > 0 && (
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
|
||||||
<Commands push={push} pages={templatePageLinks} />
|
<Commands push={push} pages={templatePageLinks} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
|
||||||
<Commands push={push} pages={SETTINGS_PAGES} />
|
<Commands push={push} pages={SETTINGS_PAGES} />
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
|
||||||
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
|
||||||
Change language
|
Change language
|
||||||
@ -255,6 +262,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
|
|||||||
Change theme
|
Change theme
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
|
||||||
<Commands push={push} pages={searchResults} />
|
<Commands push={push} pages={searchResults} />
|
||||||
|
|||||||
@ -153,7 +153,9 @@ export const DirectTemplatePageView = ({
|
|||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={template.id}
|
key={template.id}
|
||||||
documentData={template.templateDocumentData}
|
envelopeItem={template.envelopeItems[0]}
|
||||||
|
token={directTemplateRecipient.token}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
actionVerb = 'sign',
|
actionVerb = 'sign',
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: DocumentSigningAuthAccountProps) => {
|
}: DocumentSigningAuthAccountProps) => {
|
||||||
const { recipient } = useRequiredDocumentSigningAuthContext();
|
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
|
||||||
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
try {
|
try {
|
||||||
setIsSigningOut(true);
|
setIsSigningOut(true);
|
||||||
|
|
||||||
|
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
redirectPath: `/signin#email=${email}`,
|
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setIsSigningOut(false);
|
setIsSigningOut(false);
|
||||||
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
|
||||||
<span>
|
<span>
|
||||||
<Trans>
|
{isDirectTemplate ? (
|
||||||
To mark this document as viewed, you need to be logged in as{' '}
|
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
|
||||||
<strong>{recipient.email}</strong>
|
) : (
|
||||||
</Trans>
|
<Trans>
|
||||||
|
To mark this document as viewed, you need to be logged in as{' '}
|
||||||
|
<strong>{recipient.email}</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
{/* Todo: Translate */}
|
{isDirectTemplate ? (
|
||||||
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
|
<Trans>
|
||||||
in as <strong>{recipient.email}</strong>
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||||
|
logged in.
|
||||||
|
</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be
|
||||||
|
logged in as <strong>{recipient.email}</strong>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
|
|||||||
@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onReauthFormSubmit,
|
onReauthFormSubmit,
|
||||||
}: DocumentSigningAuthDialogProps) => {
|
}: DocumentSigningAuthDialogProps) => {
|
||||||
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
|
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
|
||||||
|
useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
// Filter out EXPLICIT_NONE from available auth types for the chooser
|
||||||
const validAuthTypes = availableAuthTypes.filter(
|
const validAuthTypes = availableAuthTypes.filter(
|
||||||
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
|
|||||||
match({ documentAuthType: selectedAuthType, user })
|
match({ documentAuthType: selectedAuthType, user })
|
||||||
.with(
|
.with(
|
||||||
{ documentAuthType: DocumentAuth.ACCOUNT },
|
{ documentAuthType: DocumentAuth.ACCOUNT },
|
||||||
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
|
{
|
||||||
|
user: P.when(
|
||||||
|
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
|
||||||
|
),
|
||||||
|
}, // Assume all current auth methods requires them to be logged in.
|
||||||
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
|
||||||
)
|
)
|
||||||
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (
|
||||||
|
|||||||
@ -40,6 +40,7 @@ export type DocumentSigningAuthContextValue = {
|
|||||||
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
|
||||||
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
|
||||||
isAuthRedirectRequired: boolean;
|
isAuthRedirectRequired: boolean;
|
||||||
|
isDirectTemplate?: boolean;
|
||||||
isCurrentlyAuthenticating: boolean;
|
isCurrentlyAuthenticating: boolean;
|
||||||
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
setIsCurrentlyAuthenticating: (_value: boolean) => void;
|
||||||
passkeyData: PasskeyData;
|
passkeyData: PasskeyData;
|
||||||
@ -68,6 +69,7 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
|||||||
export interface DocumentSigningAuthProviderProps {
|
export interface DocumentSigningAuthProviderProps {
|
||||||
documentAuthOptions: Envelope['authOptions'];
|
documentAuthOptions: Envelope['authOptions'];
|
||||||
recipient: SigningAuthRecipient;
|
recipient: SigningAuthRecipient;
|
||||||
|
isDirectTemplate?: boolean;
|
||||||
user?: SessionUser | null;
|
user?: SessionUser | null;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
@ -75,6 +77,7 @@ export interface DocumentSigningAuthProviderProps {
|
|||||||
export const DocumentSigningAuthProvider = ({
|
export const DocumentSigningAuthProvider = ({
|
||||||
documentAuthOptions: initialDocumentAuthOptions,
|
documentAuthOptions: initialDocumentAuthOptions,
|
||||||
recipient: initialRecipient,
|
recipient: initialRecipient,
|
||||||
|
isDirectTemplate = false,
|
||||||
user,
|
user,
|
||||||
children,
|
children,
|
||||||
}: DocumentSigningAuthProviderProps) => {
|
}: DocumentSigningAuthProviderProps) => {
|
||||||
@ -204,6 +207,7 @@ export const DocumentSigningAuthProvider = ({
|
|||||||
derivedRecipientAccessAuth,
|
derivedRecipientAccessAuth,
|
||||||
derivedRecipientActionAuth,
|
derivedRecipientActionAuth,
|
||||||
isAuthRedirectRequired,
|
isAuthRedirectRequired,
|
||||||
|
isDirectTemplate,
|
||||||
isCurrentlyAuthenticating,
|
isCurrentlyAuthenticating,
|
||||||
setIsCurrentlyAuthenticating,
|
setIsCurrentlyAuthenticating,
|
||||||
passkeyData,
|
passkeyData,
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import {
|
|||||||
} from '@documenso/ui/primitives/form/form';
|
} from '@documenso/ui/primitives/form/form';
|
||||||
import { Input } from '@documenso/ui/primitives/input';
|
import { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form';
|
||||||
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
|
||||||
|
|
||||||
@ -102,6 +103,8 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
|
|
||||||
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
|
||||||
|
|
||||||
|
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const form = useForm<TNextSignerFormSchema>({
|
const form = useForm<TNextSignerFormSchema>({
|
||||||
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -267,7 +270,12 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
<Trans>Your Name</Trans>
|
<Trans>Your Name</Trans>
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="mt-2"
|
||||||
|
placeholder={t`Enter your name`}
|
||||||
|
disabled={isNameLocked}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
@ -289,6 +297,7 @@ export const DocumentSigningCompleteDialog = ({
|
|||||||
type="email"
|
type="email"
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
placeholder={t`Enter your email`}
|
placeholder={t`Enter your email`}
|
||||||
|
disabled={!!field.value && isEmailLocked}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import { match } from 'ts-pattern';
|
|||||||
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
|
import { BrandingLogo } from '../branding-logo';
|
||||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
import { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
|
||||||
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
||||||
@ -15,6 +18,8 @@ import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
|
|||||||
export const DocumentSigningMobileWidget = () => {
|
export const DocumentSigningMobileWidget = () => {
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
|
||||||
useRequiredEnvelopeSigningContext();
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
@ -29,7 +34,7 @@ export const DocumentSigningMobileWidget = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
|
<div className="pointer-events-none fixed bottom-0 left-0 right-0 z-50 flex justify-center px-2 pb-2 sm:px-4 sm:pb-6">
|
||||||
<div className="pointer-events-auto w-full max-w-2xl">
|
<div className="pointer-events-auto w-full max-w-[760px]">
|
||||||
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
<div className="bg-card border-border overflow-hidden rounded-xl border shadow-2xl">
|
||||||
{/* Main Header Bar */}
|
{/* Main Header Bar */}
|
||||||
<div className="flex items-center justify-between gap-4 p-4">
|
<div className="flex items-center justify-between gap-4 p-4">
|
||||||
@ -114,6 +119,13 @@ export const DocumentSigningMobileWidget = () => {
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
|
||||||
<EnvelopeSignerForm />
|
<EnvelopeSignerForm />
|
||||||
|
|
||||||
|
{!hidePoweredBy && (
|
||||||
|
<div className="bg-primary text-primary-foreground mt-2 inline-block rounded px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:hidden">
|
||||||
|
<span>Powered by</span>
|
||||||
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -245,7 +245,12 @@ export const DocumentSigningPageViewV1 = ({
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
<PDFViewer
|
||||||
|
key={document.envelopeItems[0].id}
|
||||||
|
envelopeItem={document.envelopeItems[0]}
|
||||||
|
token={recipient.token}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -22,7 +22,9 @@ import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog
|
|||||||
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
|
||||||
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog';
|
||||||
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog';
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
|
import { BrandingLogo } from '../branding-logo';
|
||||||
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover';
|
||||||
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
import { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
|
||||||
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
|
||||||
@ -48,6 +50,13 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
selectedAssistantRecipientFields,
|
selectedAssistantRecipientFields,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isEmbed = false,
|
||||||
|
allowDocumentRejection = true,
|
||||||
|
hidePoweredBy = true,
|
||||||
|
onDocumentRejected,
|
||||||
|
} = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
* The total remaining fields remaining for the current recipient or selected assistant recipient.
|
||||||
*
|
*
|
||||||
@ -77,7 +86,7 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
<div className="flex h-[calc(100vh-4rem)] w-screen">
|
||||||
{/* Left Section - Step Navigation */}
|
{/* Left Section - Step Navigation */}
|
||||||
<div className="bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
|
<div className="embed--DocumentWidgetContainer bg-background border-border hidden w-80 flex-shrink-0 flex-col overflow-y-auto border-r py-4 lg:flex">
|
||||||
<div className="px-4">
|
<div className="px-4">
|
||||||
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
|
||||||
{match(recipient.role)
|
{match(recipient.role)
|
||||||
@ -107,7 +116,7 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
|
||||||
<EnvelopeSignerForm />
|
<EnvelopeSignerForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -116,7 +125,7 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
|
|
||||||
{/* Quick Actions. */}
|
{/* Quick Actions. */}
|
||||||
{!isDirectTemplate && (
|
{!isDirectTemplate && (
|
||||||
<div className="space-y-3 px-4">
|
<div className="embed--Actions space-y-3 px-4">
|
||||||
<h4 className="text-foreground text-sm font-semibold">
|
<h4 className="text-foreground text-sm font-semibold">
|
||||||
<Trans>Actions</Trans>
|
<Trans>Actions</Trans>
|
||||||
</h4>
|
</h4>
|
||||||
@ -145,10 +154,21 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{envelope.type === EnvelopeType.DOCUMENT && (
|
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
token={recipient.token}
|
token={recipient.token}
|
||||||
|
onRejected={
|
||||||
|
onDocumentRejected &&
|
||||||
|
((reason) =>
|
||||||
|
onDocumentRejected({
|
||||||
|
token: recipient.token,
|
||||||
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
reason,
|
||||||
|
}))
|
||||||
|
}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -164,18 +184,22 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer of left sidebar. */}
|
<div className="embed--DocumentWidgetFooter">
|
||||||
<div className="mt-auto px-4">
|
{/* Footer of left sidebar. */}
|
||||||
<Button asChild variant="ghost" className="w-full justify-start">
|
{!isEmbed && (
|
||||||
<Link to="/">
|
<div className="mt-auto px-4">
|
||||||
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
<Button asChild variant="ghost" className="w-full justify-start">
|
||||||
<Trans>Return</Trans>
|
<Link to="/">
|
||||||
</Link>
|
<ArrowLeftIcon className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
<Trans>Return</Trans>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* Horizontal envelope item selector */}
|
{/* Horizontal envelope item selector */}
|
||||||
{envelopeItems.length > 1 && (
|
{envelopeItems.length > 1 && (
|
||||||
@ -202,12 +226,11 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
|
||||||
{currentEnvelopeItem ? (
|
{currentEnvelopeItem ? (
|
||||||
<PDFViewerKonvaLazy
|
<PDFViewerKonvaLazy
|
||||||
renderer="signing"
|
renderer="signing"
|
||||||
key={currentEnvelopeItem.id}
|
key={currentEnvelopeItem.id}
|
||||||
documentDataId={currentEnvelopeItem.documentDataId}
|
|
||||||
customPageRenderer={EnvelopeSignerPageRenderer}
|
customPageRenderer={EnvelopeSignerPageRenderer}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@ -219,9 +242,20 @@ export const DocumentSigningPageViewV2 = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile widget - Additional padding to allow users to scroll */}
|
{/* Mobile widget - Additional padding to allow users to scroll */}
|
||||||
<div className="block pb-16 md:hidden">
|
<div className="block pb-28 lg:hidden">
|
||||||
<DocumentSigningMobileWidget />
|
<DocumentSigningMobileWidget />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!hidePoweredBy && (
|
||||||
|
<a
|
||||||
|
href="https://documenso.com"
|
||||||
|
target="_blank"
|
||||||
|
className="bg-primary text-primary-foreground fixed bottom-0 right-0 z-40 hidden cursor-pointer rounded-tl px-2 py-1 text-xs font-medium opacity-60 hover:opacity-100 lg:block"
|
||||||
|
>
|
||||||
|
<span>Powered by</span>
|
||||||
|
<BrandingLogo className="ml-2 inline-block h-[14px]" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export type EnvelopeSigningContextValue = {
|
|||||||
_fieldId: number,
|
_fieldId: number,
|
||||||
_value: TSignEnvelopeFieldValue,
|
_value: TSignEnvelopeFieldValue,
|
||||||
authOptions?: TRecipientActionAuth,
|
authOptions?: TRecipientActionAuth,
|
||||||
) => Promise<void>;
|
) => Promise<Pick<Field, 'id' | 'inserted'>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
|
||||||
@ -296,16 +296,19 @@ export const EnvelopeSigningProvider = ({
|
|||||||
) => {
|
) => {
|
||||||
// Set the field locally for direct templates.
|
// Set the field locally for direct templates.
|
||||||
if (isDirectTemplate) {
|
if (isDirectTemplate) {
|
||||||
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
|
||||||
return;
|
|
||||||
|
return signedField;
|
||||||
}
|
}
|
||||||
|
|
||||||
await signEnvelopeField({
|
const { signedField } = await signEnvelopeField({
|
||||||
token: envelopeData.recipient.token,
|
token: envelopeData.recipient.token,
|
||||||
fieldId,
|
fieldId,
|
||||||
fieldValue,
|
fieldValue,
|
||||||
authOptions,
|
authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return signedField;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDirectTemplateFieldInsertion = (
|
const handleDirectTemplateFieldInsertion = (
|
||||||
@ -363,6 +366,8 @@ export const EnvelopeSigningProvider = ({
|
|||||||
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return updatedField;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import type { DocumentData, EnvelopeItem } from '@prisma/client';
|
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
|
||||||
|
import { DownloadIcon } from 'lucide-react';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -22,9 +23,10 @@ import {
|
|||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
|
||||||
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
|
||||||
import { ShareDocumentDownloadButton } from '../share-document-download-button';
|
|
||||||
|
|
||||||
export type DocumentCertificateQRViewProps = {
|
export type DocumentCertificateQRViewProps = {
|
||||||
documentId: number;
|
documentId: number;
|
||||||
@ -34,6 +36,7 @@ export type DocumentCertificateQRViewProps = {
|
|||||||
documentTeamUrl: string;
|
documentTeamUrl: string;
|
||||||
recipientCount?: number;
|
recipientCount?: number;
|
||||||
completedDate?: Date;
|
completedDate?: Date;
|
||||||
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentCertificateQRView = ({
|
export const DocumentCertificateQRView = ({
|
||||||
@ -44,6 +47,7 @@ export const DocumentCertificateQRView = ({
|
|||||||
documentTeamUrl,
|
documentTeamUrl,
|
||||||
recipientCount = 0,
|
recipientCount = 0,
|
||||||
completedDate,
|
completedDate,
|
||||||
|
token,
|
||||||
}: DocumentCertificateQRViewProps) => {
|
}: DocumentCertificateQRViewProps) => {
|
||||||
const { data: documentViaUser } = trpc.document.get.useQuery({
|
const { data: documentViaUser } = trpc.document.get.useQuery({
|
||||||
documentId,
|
documentId,
|
||||||
@ -96,11 +100,12 @@ export const DocumentCertificateQRView = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{internalVersion === 2 ? (
|
{internalVersion === 2 ? (
|
||||||
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
|
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
|
||||||
<DocumentCertificateQrV2
|
<DocumentCertificateQrV2
|
||||||
title={title}
|
title={title}
|
||||||
recipientCount={recipientCount}
|
recipientCount={recipientCount}
|
||||||
formattedDate={formattedDate}
|
formattedDate={formattedDate}
|
||||||
|
token={token}
|
||||||
/>
|
/>
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
) : (
|
) : (
|
||||||
@ -119,14 +124,27 @@ export const DocumentCertificateQRView = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShareDocumentDownloadButton
|
<EnvelopeDownloadDialog
|
||||||
title={title}
|
envelopeId={envelopeItems[0].envelopeId}
|
||||||
documentData={envelopeItems[0].documentData}
|
envelopeStatus={DocumentStatus.COMPLETED}
|
||||||
|
envelopeItems={envelopeItems}
|
||||||
|
token={token}
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 w-full">
|
||||||
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
|
<PDFViewer
|
||||||
|
key={envelopeItems[0].id}
|
||||||
|
envelopeItem={envelopeItems[0]}
|
||||||
|
token={token}
|
||||||
|
version="signed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -138,14 +156,16 @@ type DocumentCertificateQrV2Props = {
|
|||||||
title: string;
|
title: string;
|
||||||
recipientCount: number;
|
recipientCount: number;
|
||||||
formattedDate: string;
|
formattedDate: string;
|
||||||
|
token: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentCertificateQrV2 = ({
|
const DocumentCertificateQrV2 = ({
|
||||||
title,
|
title,
|
||||||
recipientCount,
|
recipientCount,
|
||||||
formattedDate,
|
formattedDate,
|
||||||
|
token,
|
||||||
}: DocumentCertificateQrV2Props) => {
|
}: DocumentCertificateQrV2Props) => {
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-start">
|
<div className="flex min-h-screen flex-col items-start">
|
||||||
@ -163,12 +183,18 @@ const DocumentCertificateQrV2 = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentEnvelopeItem && (
|
<EnvelopeDownloadDialog
|
||||||
<ShareDocumentDownloadButton
|
envelopeId={envelopeItems[0].envelopeId}
|
||||||
title={title}
|
envelopeStatus={DocumentStatus.COMPLETED}
|
||||||
documentData={currentEnvelopeItem.documentData}
|
envelopeItems={envelopeItems}
|
||||||
/>
|
token={token}
|
||||||
)}
|
trigger={
|
||||||
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 w-full">
|
<div className="mt-12 w-full">
|
||||||
|
|||||||
@ -441,9 +441,10 @@ export const DocumentEditForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={document.documentData.id}
|
key={document.envelopeItems[0].id}
|
||||||
documentData={document.documentData}
|
envelopeItem={document.envelopeItems[0]}
|
||||||
document={document}
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,18 +1,14 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
|
||||||
@ -23,9 +19,6 @@ export type DocumentPageViewButtonProps = {
|
|||||||
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
|
||||||
|
|
||||||
const isRecipient = !!recipient;
|
const isRecipient = !!recipient;
|
||||||
@ -37,25 +30,6 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
const documentsPath = formatDocumentsPath(envelope.team.url);
|
const documentsPath = formatDocumentsPath(envelope.team.url);
|
||||||
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
const formatPath = `${documentsPath}/${envelope.id}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
// Todo; Envelopes - Support multiple items
|
|
||||||
const envelopeItem = envelope.envelopeItems[0];
|
|
||||||
|
|
||||||
if (!envelopeItem.documentData) {
|
|
||||||
throw new Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return match({
|
return match({
|
||||||
isRecipient,
|
isRecipient,
|
||||||
isPending,
|
isPending,
|
||||||
@ -95,7 +69,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true, internalVersion: 2 }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<EnvelopeDownloadDialog
|
<EnvelopeDownloadDialog
|
||||||
envelopeId={envelope.id}
|
envelopeId={envelope.id}
|
||||||
envelopeStatus={envelope.status}
|
envelopeStatus={envelope.status}
|
||||||
@ -109,11 +83,5 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
|
||||||
<Button className="w-full" onClick={onDownloadClick}>
|
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
|
||||||
<Trans>Download</Trans>
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
.otherwise(() => null);
|
.otherwise(() => null);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus } from '@prisma/client';
|
import { DocumentStatus } from '@prisma/client';
|
||||||
@ -16,13 +15,11 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -67,64 +64,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: envelope.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadOriginalClick = async () => {
|
|
||||||
try {
|
|
||||||
const documentWithData = await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentData = documentWithData?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -147,36 +86,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{envelope.internalVersion === 2 ? (
|
<EnvelopeDownloadDialog
|
||||||
<EnvelopeDownloadDialog
|
envelopeId={envelope.id}
|
||||||
envelopeId={envelope.id}
|
envelopeStatus={envelope.status}
|
||||||
envelopeStatus={envelope.status}
|
token={recipient?.token}
|
||||||
token={recipient?.token}
|
envelopeItems={envelope.envelopeItems}
|
||||||
envelopeItems={envelope.envelopeItems}
|
trigger={
|
||||||
trigger={
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
<div>
|
||||||
<div>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Download</Trans>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isComplete && (
|
|
||||||
<DropdownMenuItem onClick={onDownloadClick}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Trans>Download</Trans>
|
<Trans>Download</Trans>
|
||||||
</DropdownMenuItem>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Download Original</Trans>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
<Link to={`${documentsPath}/${envelope.id}/logs`}>
|
||||||
@ -250,7 +173,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
|||||||
|
|
||||||
{isDuplicateDialogOpen && (
|
{isDuplicateDialogOpen && (
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
id={envelope.id}
|
||||||
|
token={recipient?.token}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,10 +1,20 @@
|
|||||||
import { lazy, useEffect, useState } from 'react';
|
import { lazy, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker/locale/en';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { ConstructionIcon, FileTextIcon } from 'lucide-react';
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import {
|
||||||
|
EnvelopeRenderProvider,
|
||||||
|
useCurrentEnvelopeRender,
|
||||||
|
} from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
|
||||||
|
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
|
||||||
|
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||||
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||||
@ -15,15 +25,169 @@ import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
|||||||
|
|
||||||
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
|
||||||
|
|
||||||
|
// Todo: Envelopes - Dynamically import faker
|
||||||
export const EnvelopeEditorPreviewPage = () => {
|
export const EnvelopeEditorPreviewPage = () => {
|
||||||
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
const { envelope, editorFields } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
|
||||||
'recipient',
|
'recipient',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const fieldsWithPlaceholders = useMemo(() => {
|
||||||
|
return fields.map((field) => {
|
||||||
|
const fieldMeta = ZFieldAndMetaSchema.parse(field);
|
||||||
|
|
||||||
|
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Error('Recipient not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
faker.seed(recipient.id);
|
||||||
|
|
||||||
|
const recipientName = recipient.name || faker.person.fullName();
|
||||||
|
const recipientEmail = recipient.email || faker.internet.email();
|
||||||
|
|
||||||
|
faker.seed(recipient.id + field.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
inserted: true,
|
||||||
|
...match(fieldMeta)
|
||||||
|
.with({ type: FieldType.TEXT }, ({ fieldMeta }) => {
|
||||||
|
let text = fieldMeta?.text || faker.lorem.words(5);
|
||||||
|
|
||||||
|
if (fieldMeta?.characterLimit) {
|
||||||
|
text = text.slice(0, fieldMeta?.characterLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: text,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.NUMBER }, ({ fieldMeta }) => {
|
||||||
|
let number = fieldMeta?.value ?? '';
|
||||||
|
|
||||||
|
if (number === '') {
|
||||||
|
number = faker.number
|
||||||
|
.int({
|
||||||
|
min: fieldMeta?.minValue ?? 0,
|
||||||
|
max: fieldMeta?.maxValue ?? 1000,
|
||||||
|
})
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: number,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.DATE }, () => {
|
||||||
|
const date = extractFieldInsertionValues({
|
||||||
|
fieldValue: {
|
||||||
|
type: FieldType.DATE,
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
field,
|
||||||
|
documentMeta: envelope.documentMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: date.customText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.EMAIL }, () => {
|
||||||
|
return {
|
||||||
|
customText: recipientEmail,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.NAME }, () => {
|
||||||
|
return {
|
||||||
|
customText: recipientName,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.INITIALS }, () => {
|
||||||
|
return {
|
||||||
|
customText: extractInitials(recipientName),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.RADIO }, ({ fieldMeta }) => {
|
||||||
|
const values = fieldMeta?.values ?? [];
|
||||||
|
|
||||||
|
if (values.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let customText = '';
|
||||||
|
|
||||||
|
const preselectedValue = values.findIndex((value) => value.checked);
|
||||||
|
|
||||||
|
if (preselectedValue !== -1) {
|
||||||
|
customText = preselectedValue.toString();
|
||||||
|
} else {
|
||||||
|
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
|
||||||
|
customText = randomIndex.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => {
|
||||||
|
let checkedValues: number[] = [];
|
||||||
|
|
||||||
|
const values = fieldMeta?.values ?? [];
|
||||||
|
|
||||||
|
values.forEach((value, index) => {
|
||||||
|
if (value.checked) {
|
||||||
|
checkedValues.push(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (checkedValues.length === 0 && values.length > 0) {
|
||||||
|
const numberOfValues = fieldMeta?.validationLength || 1;
|
||||||
|
|
||||||
|
checkedValues = Array.from({ length: numberOfValues }, (_, index) => index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText: toCheckboxCustomText(checkedValues),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => {
|
||||||
|
const values = fieldMeta?.values ?? [];
|
||||||
|
|
||||||
|
let customText = fieldMeta?.defaultValue || '';
|
||||||
|
|
||||||
|
if (!customText && values.length > 0) {
|
||||||
|
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
|
||||||
|
customText = values[randomIndex].value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
customText,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.SIGNATURE }, () => {
|
||||||
|
return {
|
||||||
|
customText: '',
|
||||||
|
signature: {
|
||||||
|
signatureImageAsBase64: '',
|
||||||
|
typedSignature: recipientName,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.with({ type: FieldType.FREE_SIGNATURE }, () => {
|
||||||
|
return {
|
||||||
|
customText: '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.exhaustive(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the selected recipient to the first recipient in the envelope.
|
* Set the selected recipient to the first recipient in the envelope.
|
||||||
*/
|
*/
|
||||||
@ -31,40 +195,38 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Override the parent renderer provider so we can inject custom fields.
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full">
|
<EnvelopeRenderProvider
|
||||||
<div className="flex w-full flex-col overflow-y-auto">
|
envelope={envelope}
|
||||||
{/* Horizontal envelope item selector */}
|
token={undefined}
|
||||||
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
fields={fieldsWithPlaceholders}
|
||||||
|
recipients={envelope.recipients}
|
||||||
|
overrideSettings={{
|
||||||
|
mode: 'export',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative flex h-full">
|
||||||
|
<div className="flex w-full flex-col overflow-y-auto">
|
||||||
|
{/* Horizontal envelope item selector */}
|
||||||
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
||||||
|
|
||||||
{/* Document View */}
|
{/* Document View */}
|
||||||
<div className="mt-4 flex flex-col items-center justify-center">
|
<div className="mt-4 flex flex-col items-center justify-center">
|
||||||
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
<Alert variant="warning" className="mb-4 max-w-[800px]">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Preview Mode</Trans>
|
<Trans>Preview Mode</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{/* Coming soon section */}
|
|
||||||
<div className="border-border bg-card hover:bg-accent/10 flex w-full max-w-[800px] items-center gap-4 rounded-lg border p-4 transition-colors">
|
|
||||||
<div className="flex w-full flex-col items-center justify-center gap-2 py-32">
|
|
||||||
<ConstructionIcon className="text-muted-foreground h-10 w-10" />
|
|
||||||
<h3 className="text-foreground text-sm font-semibold">
|
|
||||||
<Trans>Coming soon</Trans>
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
<Trans>This feature is coming soon</Trans>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Todo: Envelopes - Remove div after preview mode is implemented */}
|
|
||||||
<div className="hidden">
|
|
||||||
{currentEnvelopeItem !== null ? (
|
{currentEnvelopeItem !== null ? (
|
||||||
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
|
<PDFViewerKonvaLazy
|
||||||
|
renderer="editor"
|
||||||
|
customPageRenderer={EnvelopeGenericPageRenderer}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-32">
|
<div className="flex flex-col items-center justify-center py-32">
|
||||||
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
||||||
@ -78,27 +240,28 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Section - Form Fields Panel */}
|
{/* Right Section - Form Fields Panel */}
|
||||||
{currentEnvelopeItem && false && (
|
{currentEnvelopeItem && false && (
|
||||||
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
|
||||||
{/* Add fields section. */}
|
{/* Add fields section. */}
|
||||||
<section className="px-4">
|
<section className="px-4">
|
||||||
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||||
<Trans>Preivew Mode</Trans>
|
<Trans>Preivew Mode</Trans>
|
||||||
</h3> */}
|
</h3> */}
|
||||||
|
|
||||||
<Alert variant="neutral">
|
<Alert variant="neutral">
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
<Trans>Preview Mode</Trans>
|
<Trans>Preview Mode</Trans>
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<Trans>Preview what the signed document will look like with placeholder data</Trans>
|
<Trans>
|
||||||
</AlertDescription>
|
Preview what the signed document will look like with placeholder data
|
||||||
</Alert>
|
</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
{/* <Alert variant="neutral">
|
{/* <Alert variant="neutral">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
className="gap-y-1"
|
className="gap-y-1"
|
||||||
value={selectedPreviewMode}
|
value={selectedPreviewMode}
|
||||||
@ -137,36 +300,37 @@ export const EnvelopeEditorPreviewPage = () => {
|
|||||||
<div>Preview what a recipient will see</div>
|
<div>Preview what a recipient will see</div>
|
||||||
|
|
||||||
<div>Preview the signed document</div> */}
|
<div>Preview the signed document</div> */}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{false && (
|
{false && (
|
||||||
<AnimateGenericFadeInOut key={selectedPreviewMode}>
|
<AnimateGenericFadeInOut key={selectedPreviewMode}>
|
||||||
{selectedPreviewMode === 'recipient' && (
|
{selectedPreviewMode === 'recipient' && (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
|
|
||||||
{/* Recipient selector section. */}
|
{/* Recipient selector section. */}
|
||||||
<section className="px-4">
|
<section className="px-4">
|
||||||
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
<h3 className="mb-2 text-sm font-semibold text-gray-900">
|
||||||
<Trans>Selected Recipient</Trans>
|
<Trans>Selected Recipient</Trans>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<RecipientSelector
|
<RecipientSelector
|
||||||
selectedRecipient={editorFields.selectedRecipient}
|
selectedRecipient={editorFields.selectedRecipient}
|
||||||
onSelectedRecipientChange={(recipient) =>
|
onSelectedRecipientChange={(recipient) =>
|
||||||
editorFields.setSelectedRecipient(recipient.id)
|
editorFields.setSelectedRecipient(recipient.id)
|
||||||
}
|
}
|
||||||
recipients={envelope.recipients}
|
recipients={envelope.recipients}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
align="end"
|
align="end"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimateGenericFadeInOut>
|
</AnimateGenericFadeInOut>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -482,30 +482,46 @@ export const EnvelopeEditorRecipientForm = () => {
|
|||||||
|
|
||||||
const { data } = validatedFormValues;
|
const { data } = validatedFormValues;
|
||||||
|
|
||||||
|
// Weird edge case where the whole envelope is created via API
|
||||||
|
// with no signing order. If they come to this page it will show an error
|
||||||
|
// since they aren't equal and the recipient is no longer editable.
|
||||||
|
const envelopeRecipients = data.signers.map((recipient) => {
|
||||||
|
if (!canRecipientBeModified(recipient.id)) {
|
||||||
|
return {
|
||||||
|
...recipient,
|
||||||
|
signingOrder: recipient.signingOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return recipient;
|
||||||
|
});
|
||||||
|
|
||||||
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
|
const hasSigningOrderChanged = envelope.documentMeta.signingOrder !== data.signingOrder;
|
||||||
const hasAllowDictateNextSignerChanged =
|
const hasAllowDictateNextSignerChanged =
|
||||||
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
|
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
|
||||||
|
|
||||||
const hasSignersChanged =
|
const hasSignersChanged =
|
||||||
data.signers.length !== recipients.length ||
|
envelopeRecipients.length !== recipients.length ||
|
||||||
data.signers.some((signer) => {
|
envelopeRecipients.some((signer) => {
|
||||||
const recipient = recipients.find((recipient) => recipient.id === signer.id);
|
const recipient = recipients.find((recipient) => recipient.id === signer.id);
|
||||||
|
|
||||||
if (!recipient) {
|
if (!recipient) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const signerActionAuth = signer.actionAuth;
|
||||||
|
const recipientActionAuth = recipient.authOptions?.actionAuth || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
signer.email !== recipient.email ||
|
signer.email !== recipient.email ||
|
||||||
signer.name !== recipient.name ||
|
signer.name !== recipient.name ||
|
||||||
signer.role !== recipient.role ||
|
signer.role !== recipient.role ||
|
||||||
signer.signingOrder !== recipient.signingOrder ||
|
signer.signingOrder !== recipient.signingOrder ||
|
||||||
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
|
!isDeepEqual(signerActionAuth, recipientActionAuth)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasSignersChanged) {
|
if (hasSignersChanged) {
|
||||||
setRecipientsDebounced(validatedFormValues.data.signers);
|
setRecipientsDebounced(envelopeRecipients);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
|
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {
|
||||||
|
|||||||
@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
const { t, i18n } = useLingui();
|
const { t, i18n } = useLingui();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { envelope } = useCurrentEnvelopeEditor();
|
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
const organisation = useCurrentOrganisation();
|
const organisation = useCurrentOrganisation();
|
||||||
@ -186,14 +186,12 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
documentAuth: envelope.authOptions,
|
documentAuth: envelope.authOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<TAddSettingsFormSchema>({
|
const createDefaultValues = () => {
|
||||||
resolver: zodResolver(ZAddSettingsFormSchema),
|
return {
|
||||||
defaultValues: {
|
externalId: envelope.externalId || '',
|
||||||
externalId: envelope.externalId || '', // Todo: String or undefined?
|
|
||||||
visibility: envelope.visibility || '',
|
visibility: envelope.visibility || '',
|
||||||
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
|
||||||
globalActionAuth: documentAuthOption?.globalActionAuth || [],
|
globalActionAuth: documentAuthOption?.globalActionAuth || [],
|
||||||
|
|
||||||
meta: {
|
meta: {
|
||||||
subject: envelope.documentMeta.subject ?? '',
|
subject: envelope.documentMeta.subject ?? '',
|
||||||
message: envelope.documentMeta.message ?? '',
|
message: envelope.documentMeta.message ?? '',
|
||||||
@ -210,10 +208,13 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
|
||||||
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
});
|
};
|
||||||
|
|
||||||
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
|
const form = useForm<TAddSettingsFormSchema>({
|
||||||
|
resolver: zodResolver(ZAddSettingsFormSchema),
|
||||||
|
defaultValues: createDefaultValues(),
|
||||||
|
});
|
||||||
|
|
||||||
const envelopeHasBeenSent =
|
const envelopeHasBeenSent =
|
||||||
envelope.type === EnvelopeType.DOCUMENT &&
|
envelope.type === EnvelopeType.DOCUMENT &&
|
||||||
@ -239,8 +240,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
.safeParse(data.globalAccessAuth);
|
.safeParse(data.globalAccessAuth);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateEnvelope({
|
await updateEnvelopeAsync({
|
||||||
envelopeId: envelope.id,
|
|
||||||
data: {
|
data: {
|
||||||
externalId: data.externalId || null,
|
externalId: data.externalId || null,
|
||||||
visibility: data.visibility,
|
visibility: data.visibility,
|
||||||
@ -295,7 +295,7 @@ export const EnvelopeEditorSettingsDialog = ({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form.reset();
|
form.reset(createDefaultValues());
|
||||||
setActiveTab('general');
|
setActiveTab('general');
|
||||||
}, [open, form]);
|
}, [open, form]);
|
||||||
|
|
||||||
|
|||||||
@ -18,9 +18,9 @@ import {
|
|||||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||||
import { nanoid } from '@documenso/lib/universal/id';
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
|
||||||
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
|
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
|
|
||||||
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
|
||||||
trpc.envelope.item.createMany.useMutation({
|
trpc.envelope.item.createMany.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: ({ data }) => {
|
||||||
const createdEnvelopes = data.createdEnvelopeItems.filter(
|
const createdEnvelopes = data.filter(
|
||||||
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -79,10 +79,10 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
|
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: ({ data }) => {
|
||||||
setLocalEnvelope({
|
setLocalEnvelope({
|
||||||
envelopeItems: envelope.envelopeItems.map((originalItem) => {
|
envelopeItems: envelope.envelopeItems.map((originalItem) => {
|
||||||
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
|
const updatedItem = data.find((item) => item.id === originalItem.id);
|
||||||
|
|
||||||
if (updatedItem) {
|
if (updatedItem) {
|
||||||
return {
|
return {
|
||||||
@ -114,36 +114,19 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
|
|
||||||
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
|
||||||
|
|
||||||
const result = await Promise.all(
|
const payload = {
|
||||||
files.map(async (file, index) => {
|
|
||||||
try {
|
|
||||||
const response = await putPdfFile(file);
|
|
||||||
|
|
||||||
// Mark as uploaded (remove from uploading state)
|
|
||||||
return {
|
|
||||||
title: file.name,
|
|
||||||
documentDataId: response.id,
|
|
||||||
};
|
|
||||||
} catch (_error) {
|
|
||||||
setLocalFiles((prev) =>
|
|
||||||
prev.map((uploadingFile) =>
|
|
||||||
uploadingFile.id === newUploadingFiles[index].id
|
|
||||||
? { ...uploadingFile, isError: true, isUploading: false }
|
|
||||||
: uploadingFile,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const envelopeItemsToCreate = result.filter(
|
|
||||||
(item): item is { title: string; documentDataId: string } => item !== undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { createdEnvelopeItems } = await createEnvelopeItems({
|
|
||||||
envelopeId: envelope.id,
|
envelopeId: envelope.id,
|
||||||
data: envelopeItemsToCreate,
|
} satisfies TCreateEnvelopeItemsPayload;
|
||||||
}).catch((error) => {
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
formData.append('payload', JSON.stringify(payload));
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await createEnvelopeItems(formData).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
// Set error state on files in batch upload.
|
// Set error state on files in batch upload.
|
||||||
@ -165,7 +148,7 @@ export const EnvelopeEditorUploadPage = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return filteredFiles.concat(
|
return filteredFiles.concat(
|
||||||
createdEnvelopeItems.map((item) => ({
|
data.map((item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
envelopeItemId: item.id,
|
envelopeItemId: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({
|
|||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium ${
|
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
|
||||||
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
isSelected ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useMemo } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
|
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||||
import type Konva from 'konva';
|
import type Konva from 'konva';
|
||||||
|
|
||||||
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
|
||||||
@ -8,12 +9,23 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e
|
|||||||
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
import type { TEnvelope } from '@documenso/lib/types/envelope';
|
||||||
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
|
||||||
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
|
||||||
|
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
|
||||||
|
|
||||||
|
type GenericLocalField = TEnvelope['fields'][number] & {
|
||||||
|
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
|
||||||
|
};
|
||||||
|
|
||||||
export default function EnvelopeGenericPageRenderer() {
|
export default function EnvelopeGenericPageRenderer() {
|
||||||
const { i18n } = useLingui();
|
const { i18n } = useLingui();
|
||||||
|
|
||||||
const { currentEnvelopeItem, fields, getRecipientColorKey, setRenderError } =
|
const {
|
||||||
useCurrentEnvelopeRender();
|
currentEnvelopeItem,
|
||||||
|
fields,
|
||||||
|
recipients,
|
||||||
|
getRecipientColorKey,
|
||||||
|
setRenderError,
|
||||||
|
overrideSettings,
|
||||||
|
} = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
stage,
|
stage,
|
||||||
@ -29,45 +41,65 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
|
|
||||||
const { _className, scale } = pageContext;
|
const { _className, scale } = pageContext;
|
||||||
|
|
||||||
const localPageFields = useMemo(
|
const localPageFields = useMemo((): GenericLocalField[] => {
|
||||||
() =>
|
return fields
|
||||||
fields.filter(
|
.filter(
|
||||||
(field) =>
|
(field) =>
|
||||||
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
|
||||||
),
|
)
|
||||||
[fields, pageContext.pageNumber],
|
.map((field) => {
|
||||||
);
|
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
|
||||||
|
|
||||||
const unsafeRenderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
if (!recipient) {
|
||||||
|
throw new Error(`Recipient not found for field ${field.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
recipient,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
|
||||||
|
|
||||||
|
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
|
||||||
if (!pageLayer.current) {
|
if (!pageLayer.current) {
|
||||||
console.error('Layer not loaded yet');
|
console.error('Layer not loaded yet');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { recipient } = field;
|
||||||
|
|
||||||
|
const fieldTranslations = getClientSideFieldTranslations(i18n);
|
||||||
|
|
||||||
|
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
|
||||||
|
|
||||||
renderField({
|
renderField({
|
||||||
scale,
|
scale,
|
||||||
pageLayer: pageLayer.current,
|
pageLayer: pageLayer.current,
|
||||||
field: {
|
field: {
|
||||||
renderId: field.id.toString(),
|
renderId: field.id.toString(),
|
||||||
...field,
|
...field,
|
||||||
customText: '',
|
|
||||||
width: Number(field.width),
|
width: Number(field.width),
|
||||||
height: Number(field.height),
|
height: Number(field.height),
|
||||||
positionX: Number(field.positionX),
|
positionX: Number(field.positionX),
|
||||||
positionY: Number(field.positionY),
|
positionY: Number(field.positionY),
|
||||||
inserted: false,
|
customText: isInserted ? field.customText : '',
|
||||||
fieldMeta: field.fieldMeta,
|
fieldMeta: field.fieldMeta,
|
||||||
|
signature: {
|
||||||
|
signatureImageAsBase64: '',
|
||||||
|
typedSignature: fieldTranslations.SIGNATURE,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
translations: getClientSideFieldTranslations(i18n),
|
translations: fieldTranslations,
|
||||||
pageWidth: unscaledViewport.width,
|
pageWidth: unscaledViewport.width,
|
||||||
pageHeight: unscaledViewport.height,
|
pageHeight: unscaledViewport.height,
|
||||||
color: getRecipientColorKey(field.recipientId),
|
color: getRecipientColorKey(field.recipientId),
|
||||||
editable: false,
|
editable: false,
|
||||||
mode: 'sign',
|
mode: overrideSettings?.mode ?? 'sign',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
|
const renderFieldOnLayer = (field: GenericLocalField) => {
|
||||||
try {
|
try {
|
||||||
unsafeRenderFieldOnLayer(field);
|
unsafeRenderFieldOnLayer(field);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -123,6 +155,16 @@ export default function EnvelopeGenericPageRenderer() {
|
|||||||
className="relative w-full"
|
className="relative w-full"
|
||||||
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
key={`${currentEnvelopeItem.id}-renderer-${pageContext.pageNumber}`}
|
||||||
>
|
>
|
||||||
|
{overrideSettings?.showRecipientTooltip &&
|
||||||
|
localPageFields.map((field) => (
|
||||||
|
<EnvelopeRecipientFieldTooltip
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
showFieldStatus={overrideSettings?.showRecipientSigningStatus}
|
||||||
|
showRecipientTooltip={overrideSettings?.showRecipientTooltip}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* The element Konva will inject it's canvas into. */}
|
{/* The element Konva will inject it's canvas into. */}
|
||||||
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,8 @@ import { Label } from '@documenso/ui/primitives/label';
|
|||||||
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
|
||||||
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
export default function EnvelopeSignerForm() {
|
export default function EnvelopeSignerForm() {
|
||||||
@ -25,6 +27,8 @@ export default function EnvelopeSignerForm() {
|
|||||||
setSelectedAssistantRecipientId,
|
setSelectedAssistantRecipientId,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const hasSignatureField = useMemo(() => {
|
const hasSignatureField = useMemo(() => {
|
||||||
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
|
||||||
}, [recipientFields]);
|
}, [recipientFields]);
|
||||||
@ -37,7 +41,7 @@ export default function EnvelopeSignerForm() {
|
|||||||
|
|
||||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||||
return (
|
return (
|
||||||
<fieldset className="dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
||||||
value={selectedAssistantRecipient?.id?.toString()}
|
value={selectedAssistantRecipient?.id?.toString()}
|
||||||
@ -101,7 +105,8 @@ export default function EnvelopeSignerForm() {
|
|||||||
id="full-name"
|
id="full-name"
|
||||||
className="bg-background mt-2"
|
className="bg-background mt-2"
|
||||||
value={fullName}
|
value={fullName}
|
||||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
disabled={isNameLocked}
|
||||||
|
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
import { Separator } from '@documenso/ui/primitives/separator';
|
import { Separator } from '@documenso/ui/primitives/separator';
|
||||||
|
|
||||||
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||||
|
|
||||||
import { BrandingLogoIcon } from '../branding-logo-icon';
|
import { BrandingLogoIcon } from '../branding-logo-icon';
|
||||||
@ -28,7 +29,7 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
useRequiredEnvelopeSigningContext();
|
useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
<nav className="embed--DocumentWidgetHeader bg-background border-border max-w-screen flex flex-row justify-between border-b px-4 py-3 md:px-6">
|
||||||
{/* Left side - Logo and title */}
|
{/* Left side - Logo and title */}
|
||||||
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
<div className="flex min-w-0 flex-1 items-center space-x-2 md:w-auto md:flex-none">
|
||||||
<Link to="/" className="flex-shrink-0">
|
<Link to="/" className="flex-shrink-0">
|
||||||
@ -72,7 +73,7 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Desktop content */}
|
{/* Right side - Desktop content */}
|
||||||
<div className="hidden items-center space-x-2 md:flex">
|
<div className="hidden items-center space-x-2 lg:flex">
|
||||||
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
|
||||||
<Plural
|
<Plural
|
||||||
one="1 Field Remaining"
|
one="1 Field Remaining"
|
||||||
@ -85,7 +86,7 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Actions button */}
|
{/* Mobile Actions button */}
|
||||||
<div className="flex-shrink-0 md:hidden">
|
<div className="flex-shrink-0 lg:hidden">
|
||||||
<MobileDropdownMenu />
|
<MobileDropdownMenu />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@ -95,6 +96,8 @@ export const EnvelopeSignerHeader = () => {
|
|||||||
const MobileDropdownMenu = () => {
|
const MobileDropdownMenu = () => {
|
||||||
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { allowDocumentRejection } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@ -119,7 +122,7 @@ const MobileDropdownMenu = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{envelope.type === EnvelopeType.DOCUMENT && (
|
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
|
||||||
<DocumentSigningRejectDialog
|
<DocumentSigningRejectDialog
|
||||||
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
|
||||||
token={recipient.token}
|
token={recipient.token}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-rende
|
|||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
|
||||||
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
import { ZFullFieldSchema } from '@documenso/lib/types/field';
|
||||||
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
|
||||||
@ -22,6 +23,7 @@ import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-fi
|
|||||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
|
import { handleCheckboxFieldClick } from '~/utils/field-signing/checkbox-field';
|
||||||
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
|
import { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
|
||||||
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
|
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
|
||||||
@ -60,6 +62,8 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
isDirectTemplate,
|
isDirectTemplate,
|
||||||
} = useRequiredEnvelopeSigningContext();
|
} = useRequiredEnvelopeSigningContext();
|
||||||
|
|
||||||
|
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
stage,
|
stage,
|
||||||
pageLayer,
|
pageLayer,
|
||||||
@ -378,7 +382,19 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
authOptions?: TRecipientActionAuth,
|
authOptions?: TRecipientActionAuth,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
await signFieldInternal(fieldId, payload, authOptions);
|
const { inserted } = await signFieldInternal(fieldId, payload, authOptions);
|
||||||
|
|
||||||
|
// ?: The two callbacks below are used within the embedding context
|
||||||
|
if (inserted && onFieldSigned) {
|
||||||
|
const value = payload.value ? JSON.stringify(payload.value) : undefined;
|
||||||
|
const isBase64 = value ? isBase64Image(value) : undefined;
|
||||||
|
|
||||||
|
onFieldSigned({ fieldId, value, isBase64 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inserted && onFieldUnsigned) {
|
||||||
|
onFieldUnsigned({ fieldId });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
||||||
@ -413,7 +429,6 @@ export default function EnvelopeSignerPageRenderer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localPageFields.forEach((field) => {
|
localPageFields.forEach((field) => {
|
||||||
console.log('Field changed/inserted, rendering on canvas');
|
|
||||||
renderFieldOnLayer(field);
|
renderFieldOnLayer(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -2,16 +2,19 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
import { useLingui } from '@lingui/react/macro';
|
import { useLingui } from '@lingui/react/macro';
|
||||||
import { FieldType } from '@prisma/client';
|
import { FieldType } from '@prisma/client';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||||
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
import { isBase64Image } from '@documenso/lib/constants/signatures';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
|
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
|
||||||
|
|
||||||
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
import { DocumentSigningCompleteDialog } from '../document-signing/document-signing-complete-dialog';
|
||||||
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
|
||||||
|
|
||||||
@ -19,8 +22,9 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const analytics = useAnalytics();
|
const analytics = useAnalytics();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { revalidate } = useRevalidator();
|
||||||
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
@ -37,6 +41,8 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
|
||||||
|
|
||||||
|
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
|
||||||
|
|
||||||
const { mutateAsync: completeDocument, isPending } =
|
const { mutateAsync: completeDocument, isPending } =
|
||||||
trpc.recipient.completeDocumentWithToken.useMutation();
|
trpc.recipient.completeDocumentWithToken.useMutation();
|
||||||
|
|
||||||
@ -68,25 +74,54 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
nextSigner?: { name: string; email: string },
|
nextSigner?: { name: string; email: string },
|
||||||
accessAuthOptions?: TRecipientAccessAuth,
|
accessAuthOptions?: TRecipientAccessAuth,
|
||||||
) => {
|
) => {
|
||||||
const payload = {
|
try {
|
||||||
token: recipient.token,
|
const payload = {
|
||||||
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
token: recipient.token,
|
||||||
authOptions: accessAuthOptions,
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
authOptions: accessAuthOptions,
|
||||||
};
|
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
await completeDocument(payload);
|
await completeDocument(payload);
|
||||||
|
|
||||||
analytics.capture('App: Recipient has completed signing', {
|
analytics.capture('App: Recipient has completed signing', {
|
||||||
signerId: recipient.id,
|
signerId: recipient.id,
|
||||||
documentId: envelope.id,
|
documentId: envelope.id,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (envelope.documentMeta.redirectUrl) {
|
if (onDocumentCompleted) {
|
||||||
window.location.href = envelope.documentMeta.redirectUrl;
|
onDocumentCompleted({
|
||||||
} else {
|
token: recipient.token,
|
||||||
await navigate(`/sign/${recipient.token}/complete`);
|
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
|
||||||
|
recipientId: recipient.id,
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await revalidate();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.documentMeta.redirectUrl) {
|
||||||
|
window.location.href = envelope.documentMeta.redirectUrl;
|
||||||
|
} else {
|
||||||
|
await navigate(`/sign/${recipient.token}/complete`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
|
||||||
|
if (error.code !== AppErrorCode.TWO_FACTOR_AUTH_FAILED) {
|
||||||
|
toast({
|
||||||
|
title: t`Something went wrong`,
|
||||||
|
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
|
||||||
|
onDocumentError?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -105,8 +140,12 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!recipient.directToken) {
|
||||||
|
throw new Error('Recipient direct token is required');
|
||||||
|
}
|
||||||
|
|
||||||
const { token } = await createDocumentFromDirectTemplate({
|
const { token } = await createDocumentFromDirectTemplate({
|
||||||
directTemplateToken: recipient.token, // The direct template token is inserted into the recipient token for ease of use.
|
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
|
||||||
directTemplateExternalId,
|
directTemplateExternalId,
|
||||||
directRecipientName: recipientDetails?.name || fullName,
|
directRecipientName: recipientDetails?.name || fullName,
|
||||||
directRecipientEmail: recipientDetails?.email || email,
|
directRecipientEmail: recipientDetails?.email || email,
|
||||||
@ -132,18 +171,31 @@ export const EnvelopeSignerCompleteDialog = () => {
|
|||||||
|
|
||||||
const redirectUrl = envelope.documentMeta.redirectUrl;
|
const redirectUrl = envelope.documentMeta.redirectUrl;
|
||||||
|
|
||||||
|
if (onDocumentCompleted) {
|
||||||
|
await navigate({
|
||||||
|
pathname: `/embed/sign/${token}`,
|
||||||
|
search: window.location.search,
|
||||||
|
hash: window.location.hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
window.location.href = redirectUrl;
|
window.location.href = redirectUrl;
|
||||||
} else {
|
} else {
|
||||||
await navigate(`/sign/${token}/complete`);
|
await navigate(`/sign/${token}/complete`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log('err', err);
|
||||||
toast({
|
toast({
|
||||||
title: t`Something went wrong`,
|
title: t`Something went wrong`,
|
||||||
description: t`We were unable to submit this document at this time. Please try again later.`,
|
description: t`We were unable to submit this document at this time. Please try again later.`,
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDocumentError?.();
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
|
||||||
import { Download } from 'lucide-react';
|
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import type { DocumentData } from '@documenso/prisma/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
export type ShareDocumentDownloadButtonProps = {
|
|
||||||
title: string;
|
|
||||||
documentData: DocumentData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ShareDocumentDownloadButton = ({
|
|
||||||
title,
|
|
||||||
documentData,
|
|
||||||
}: ShareDocumentDownloadButtonProps) => {
|
|
||||||
const { _ } = useLingui();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
setIsDownloading(true);
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsDownloading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button loading={isDownloading} onClick={onDownloadClick}>
|
|
||||||
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
|
|
||||||
<Trans>Download</Trans>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -313,8 +313,10 @@ export const TemplateEditForm = ({
|
|||||||
>
|
>
|
||||||
<CardContent className="p-2">
|
<CardContent className="p-2">
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
key={templateDocumentData.id}
|
key={template.envelopeItems[0].id}
|
||||||
documentData={templateDocumentData}
|
envelopeItem={template.envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -1,19 +1,14 @@
|
|||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
|
||||||
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { useCurrentTeam } from '~/providers/team';
|
import { useCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
@ -25,8 +20,6 @@ export type DocumentsTableActionButtonProps = {
|
|||||||
|
|
||||||
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
|
||||||
|
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
@ -44,39 +37,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = !recipient
|
|
||||||
? await trpcClient.document.get.query(
|
|
||||||
{
|
|
||||||
documentId: row.id,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
context: {
|
|
||||||
teamId: team?.id?.toString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||||
return null;
|
return null;
|
||||||
@ -134,7 +94,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
<Trans>View</Trans>
|
<Trans>View</Trans>
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true, internalVersion: 2 }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<EnvelopeDownloadDialog
|
<EnvelopeDownloadDialog
|
||||||
envelopeId={row.envelopeId}
|
envelopeId={row.envelopeId}
|
||||||
envelopeStatus={row.status}
|
envelopeStatus={row.status}
|
||||||
@ -147,11 +107,5 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
|
||||||
<Button className="w-32" onClick={onDownloadClick}>
|
|
||||||
<Download className="-ml-1 mr-2 inline h-4 w-4" />
|
|
||||||
<Trans>Download</Trans>
|
|
||||||
</Button>
|
|
||||||
))
|
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Edit,
|
Edit,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FileDown,
|
|
||||||
FolderInput,
|
FolderInput,
|
||||||
Loader,
|
Loader,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
@ -20,12 +19,10 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -34,7 +31,6 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@documenso/ui/primitives/dropdown-menu';
|
} from '@documenso/ui/primitives/dropdown-menu';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
||||||
|
|
||||||
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
|
||||||
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
|
||||||
@ -56,7 +52,6 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const team = useCurrentTeam();
|
const team = useCurrentTeam();
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { _ } = useLingui();
|
const { _ } = useLingui();
|
||||||
|
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
@ -76,58 +71,6 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
const documentsPath = formatDocumentsPath(team.url);
|
const documentsPath = formatDocumentsPath(team.url);
|
||||||
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = !recipient
|
|
||||||
? await trpcClient.document.get.query({
|
|
||||||
documentId: row.id,
|
|
||||||
})
|
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDownloadOriginalClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = !recipient
|
|
||||||
? await trpcClient.document.get.query({
|
|
||||||
documentId: row.id,
|
|
||||||
})
|
|
||||||
: await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title, version: 'original' });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -178,33 +121,19 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{row.internalVersion === 2 ? (
|
<EnvelopeDownloadDialog
|
||||||
<EnvelopeDownloadDialog
|
envelopeId={row.envelopeId}
|
||||||
envelopeId={row.envelopeId}
|
envelopeStatus={row.status}
|
||||||
envelopeStatus={row.status}
|
token={recipient?.token}
|
||||||
token={recipient?.token}
|
trigger={
|
||||||
trigger={
|
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
<div>
|
||||||
<div>
|
<Download className="mr-2 h-4 w-4" />
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Trans>Download</Trans>
|
||||||
<Trans>Download</Trans>
|
</div>
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Download</Trans>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
}
|
||||||
<DropdownMenuItem onClick={onDownloadOriginalClick}>
|
/>
|
||||||
<FileDown className="mr-2 h-4 w-4" />
|
|
||||||
<Trans>Download Original</Trans>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
@ -273,7 +202,8 @@ export const DocumentsTableActionDropdown = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<DocumentDuplicateDialog
|
<DocumentDuplicateDialog
|
||||||
id={row.id}
|
id={row.envelopeId}
|
||||||
|
token={recipient?.token}
|
||||||
open={isDuplicateDialogOpen}
|
open={isDuplicateDialogOpen}
|
||||||
onOpenChange={setDuplicateDialogOpen}
|
onOpenChange={setDuplicateDialogOpen}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,11 +10,9 @@ import { DateTime } from 'luxon';
|
|||||||
import { Link, useSearchParams } from 'react-router';
|
import { Link, useSearchParams } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
|
|
||||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { trpc as trpcClient } from '@documenso/trpc/client';
|
|
||||||
import { trpc } from '@documenso/trpc/react';
|
import { trpc } from '@documenso/trpc/react';
|
||||||
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
|
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
@ -28,6 +26,7 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
|||||||
import { DocumentStatus } from '~/components/general/document/document-status';
|
import { DocumentStatus } from '~/components/general/document/document-status';
|
||||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||||
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
|
||||||
|
|
||||||
export type DocumentsTableProps = {
|
export type DocumentsTableProps = {
|
||||||
@ -199,28 +198,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDownloadClick = async () => {
|
|
||||||
try {
|
|
||||||
const document = await trpcClient.document.getDocumentByToken.query({
|
|
||||||
token: recipient.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = document?.documentData;
|
|
||||||
|
|
||||||
if (!documentData) {
|
|
||||||
throw Error('No document available');
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadPDF({ documentData, fileName: row.title });
|
|
||||||
} catch (err) {
|
|
||||||
toast({
|
|
||||||
title: _(msg`Something went wrong`),
|
|
||||||
description: _(msg`An error occurred while downloading your document.`),
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
|
||||||
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
if (recipient?.role === RecipientRole.CC && isComplete === false) {
|
||||||
return null;
|
return null;
|
||||||
@ -230,6 +207,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
isPending,
|
isPending,
|
||||||
isComplete,
|
isComplete,
|
||||||
isSigned,
|
isSigned,
|
||||||
|
internalVersion: row.internalVersion,
|
||||||
})
|
})
|
||||||
.with({ isPending: true, isSigned: false }, () => (
|
.with({ isPending: true, isSigned: false }, () => (
|
||||||
<Button className="w-32" asChild>
|
<Button className="w-32" asChild>
|
||||||
@ -263,10 +241,17 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
|
|||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
.with({ isComplete: true }, () => (
|
.with({ isComplete: true }, () => (
|
||||||
<Button className="w-32" onClick={onDownloadClick}>
|
<EnvelopeDownloadDialog
|
||||||
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
envelopeId={row.envelopeId}
|
||||||
<Trans>Download</Trans>
|
envelopeStatus={row.status}
|
||||||
</Button>
|
token={recipient?.token}
|
||||||
|
trigger={
|
||||||
|
<Button className="w-32">
|
||||||
|
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
|
||||||
|
<Trans>Download</Trans>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
.otherwise(() => <div></div>);
|
.otherwise(() => <div></div>);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -147,8 +147,13 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
|
token={undefined}
|
||||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
fields={envelope.fields}
|
||||||
|
recipients={envelope.recipients}
|
||||||
|
overrideSettings={{
|
||||||
|
showRecipientSigningStatus: true,
|
||||||
|
showRecipientTooltip: true,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isMultiEnvelopeItem && (
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
@ -181,9 +186,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
document={envelope}
|
envelopeItem={envelope.envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
key={envelope.envelopeItems[0].id}
|
key={envelope.envelopeItems[0].id}
|
||||||
documentData={envelope.envelopeItems[0].documentData}
|
version="signed"
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -101,8 +101,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
|
|||||||
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
<EnvelopeEditorProvider initialEnvelope={envelope}>
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
recipients={envelope.recipients}
|
||||||
>
|
>
|
||||||
<EnvelopeEditor />
|
<EnvelopeEditor />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
|
|||||||
@ -170,8 +170,12 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||||
<EnvelopeRenderProvider
|
<EnvelopeRenderProvider
|
||||||
envelope={envelope}
|
envelope={envelope}
|
||||||
|
token={undefined}
|
||||||
fields={envelope.fields}
|
fields={envelope.fields}
|
||||||
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
|
recipients={envelope.recipients}
|
||||||
|
overrideSettings={{
|
||||||
|
showRecipientTooltip: true,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isMultiEnvelopeItem && (
|
{isMultiEnvelopeItem && (
|
||||||
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
|
||||||
@ -203,9 +207,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<PDFViewer
|
<PDFViewer
|
||||||
document={envelope}
|
envelopeItem={envelope.envelopeItems[0]}
|
||||||
|
token={undefined}
|
||||||
|
version="signed"
|
||||||
key={envelope.envelopeItems[0].id}
|
key={envelope.envelopeItems[0].id}
|
||||||
documentData={envelope.envelopeItems[0].documentData}
|
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -184,6 +184,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
<DocumentSigningAuthProvider
|
<DocumentSigningAuthProvider
|
||||||
documentAuthOptions={template.authOptions}
|
documentAuthOptions={template.authOptions}
|
||||||
recipient={directTemplateRecipient}
|
recipient={directTemplateRecipient}
|
||||||
|
isDirectTemplate={true}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<>
|
<>
|
||||||
@ -245,7 +246,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope}>
|
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@ -492,7 +492,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
|||||||
recipient={recipient}
|
recipient={recipient}
|
||||||
user={user}
|
user={user}
|
||||||
>
|
>
|
||||||
<EnvelopeRenderProvider envelope={envelope}>
|
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||||
<DocumentSigningPageViewV2 />
|
<DocumentSigningPageViewV2 />
|
||||||
</EnvelopeRenderProvider>
|
</EnvelopeRenderProvider>
|
||||||
</DocumentSigningAuthProvider>
|
</DocumentSigningAuthProvider>
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { msg } from '@lingui/core/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
import { useLingui } from '@lingui/react';
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||||
import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
|
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react';
|
||||||
import { Link, useRevalidator } from 'react-router';
|
import { Link, useRevalidator } from 'react-router';
|
||||||
import { match } from 'ts-pattern';
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
@ -20,14 +19,13 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
|
|||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
|
||||||
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
|
|
||||||
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
|
|
||||||
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
|
||||||
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
import { SigningCard3D } from '@documenso/ui/components/signing-card';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Badge } from '@documenso/ui/primitives/badge';
|
import { Badge } from '@documenso/ui/primitives/badge';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
import { Button } from '@documenso/ui/primitives/button';
|
||||||
|
|
||||||
|
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
|
||||||
import { ClaimAccount } from '~/components/general/claim-account';
|
import { ClaimAccount } from '~/components/general/claim-account';
|
||||||
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
|
||||||
|
|
||||||
@ -207,24 +205,16 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
|
|||||||
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
|
||||||
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
<DocumentShareButton documentId={document.id} token={recipient.token} />
|
||||||
|
|
||||||
{isDocumentCompleted(document.status) ? (
|
{isDocumentCompleted(document.status) && (
|
||||||
<DocumentDownloadButton
|
<EnvelopeDownloadDialog
|
||||||
className="flex-1"
|
envelopeId={document.envelopeId}
|
||||||
fileName={document.title}
|
envelopeStatus={document.status}
|
||||||
documentData={document.documentData}
|
envelopeItems={document.envelopeItems}
|
||||||
disabled={!isDocumentCompleted(document.status)}
|
token={recipient?.token}
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<DocumentDialog
|
|
||||||
documentData={document.documentData}
|
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button type="button" variant="outline" className="flex-1">
|
||||||
className="text-[11px]"
|
<DownloadIcon className="mr-2 h-5 w-5" />
|
||||||
title={_(msg`Signatures will appear once the document has been completed`)}
|
<Trans>Download</Trans>
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
|
|
||||||
<Trans>View Original Document</Trans>
|
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
document,
|
document,
|
||||||
|
token: slug,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +75,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function SharePage() {
|
export default function SharePage() {
|
||||||
const { document } = useLoaderData<typeof loader>();
|
const { document, token } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
if (document) {
|
if (document) {
|
||||||
return (
|
return (
|
||||||
@ -86,6 +87,7 @@ export default function SharePage() {
|
|||||||
envelopeItems={document.envelopeItems}
|
envelopeItems={document.envelopeItems}
|
||||||
recipientCount={document.recipientCount}
|
recipientCount={document.recipientCount}
|
||||||
completedDate={document.completedAt ?? undefined}
|
completedDate={document.completedAt ?? undefined}
|
||||||
|
token={token}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Trans } from '@lingui/react/macro';
|
import { Trans } from '@lingui/react/macro';
|
||||||
import { Link, redirect } from 'react-router';
|
import { Link, redirect } from 'react-router';
|
||||||
|
|
||||||
@ -9,6 +11,7 @@ import {
|
|||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||||
|
|
||||||
import { SignInForm } from '~/components/forms/signin';
|
import { SignInForm } from '~/components/forms/signin';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -28,8 +31,12 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
|
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||||
|
|
||||||
|
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
throw redirect('/');
|
throw redirect(returnTo || '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -37,12 +44,28 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
|
returnTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
export default function SignIn({ loaderData }: Route.ComponentProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
const {
|
||||||
loaderData;
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
|
isOIDCSSOEnabled,
|
||||||
|
oidcProviderLabel,
|
||||||
|
returnTo,
|
||||||
|
} = loaderData;
|
||||||
|
|
||||||
|
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
|
||||||
|
setIsEmbeddedRedirect(params.get('embedded') === 'true');
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen max-w-lg px-4">
|
<div className="w-screen max-w-lg px-4">
|
||||||
@ -61,13 +84,17 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
|
|||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
|
returnTo={returnTo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
|
||||||
<p className="text-muted-foreground mt-6 text-center text-sm">
|
<p className="text-muted-foreground mt-6 text-center text-sm">
|
||||||
<Trans>
|
<Trans>
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
|
<Link
|
||||||
|
to={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
|
||||||
|
className="text-documenso-700 duration-200 hover:opacity-70"
|
||||||
|
>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
import { env } from '@documenso/lib/utils/env';
|
import { env } from '@documenso/lib/utils/env';
|
||||||
|
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||||
|
|
||||||
import { SignUpForm } from '~/components/forms/signup';
|
import { SignUpForm } from '~/components/forms/signup';
|
||||||
import { appMetaTags } from '~/utils/meta';
|
import { appMetaTags } from '~/utils/meta';
|
||||||
@ -16,7 +17,7 @@ export function meta() {
|
|||||||
return appMetaTags('Sign Up');
|
return appMetaTags('Sign Up');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loader() {
|
export function loader({ request }: Route.LoaderArgs) {
|
||||||
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
|
||||||
|
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
@ -28,15 +29,20 @@ export function loader() {
|
|||||||
throw redirect('/signin');
|
throw redirect('/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
|
||||||
|
|
||||||
|
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
isMicrosoftSSOEnabled,
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
|
returnTo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
export default function SignUp({ loaderData }: Route.ComponentProps) {
|
||||||
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SignUpForm
|
<SignUpForm
|
||||||
@ -44,6 +50,7 @@ export default function SignUp({ loaderData }: Route.ComponentProps) {
|
|||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
|
returnTo={returnTo}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,11 +2,14 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
IS_GOOGLE_SSO_ENABLED,
|
IS_GOOGLE_SSO_ENABLED,
|
||||||
|
IS_MICROSOFT_SSO_ENABLED,
|
||||||
IS_OIDC_SSO_ENABLED,
|
IS_OIDC_SSO_ENABLED,
|
||||||
OIDC_PROVIDER_LABEL,
|
OIDC_PROVIDER_LABEL,
|
||||||
} from '@documenso/lib/constants/auth';
|
} from '@documenso/lib/constants/auth';
|
||||||
|
|
||||||
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
import { EmbedAuthenticationRequired } from '~/components/embed/embed-authentication-required';
|
||||||
|
import { EmbedDocumentCompleted } from '~/components/embed/embed-document-completed';
|
||||||
|
import { EmbedDocumentRejected } from '~/components/embed/embed-document-rejected';
|
||||||
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
import { EmbedDocumentWaitingForTurn } from '~/components/embed/embed-document-waiting-for-turn';
|
||||||
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
import { EmbedPaywall } from '~/components/embed/embed-paywall';
|
||||||
|
|
||||||
@ -29,11 +32,13 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
|||||||
export function loader() {
|
export function loader() {
|
||||||
// SSR env variables.
|
// SSR env variables.
|
||||||
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
|
||||||
|
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
|
||||||
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
|
||||||
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isGoogleSSOEnabled,
|
isGoogleSSOEnabled,
|
||||||
|
isMicrosoftSSOEnabled,
|
||||||
isOIDCSSOEnabled,
|
isOIDCSSOEnabled,
|
||||||
oidcProviderLabel,
|
oidcProviderLabel,
|
||||||
};
|
};
|
||||||
@ -44,15 +49,19 @@ export default function Layout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
||||||
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
|
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
|
||||||
|
loaderData || {};
|
||||||
|
|
||||||
const error = useRouteError();
|
const error = useRouteError();
|
||||||
|
|
||||||
|
console.log({ routeError: error });
|
||||||
|
|
||||||
if (isRouteErrorResponse(error)) {
|
if (isRouteErrorResponse(error)) {
|
||||||
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
|
||||||
return (
|
return (
|
||||||
<EmbedAuthenticationRequired
|
<EmbedAuthenticationRequired
|
||||||
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
isGoogleSSOEnabled={isGoogleSSOEnabled}
|
||||||
|
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
|
||||||
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
isOIDCSSOEnabled={isOIDCSSOEnabled}
|
||||||
oidcProviderLabel={oidcProviderLabel}
|
oidcProviderLabel={oidcProviderLabel}
|
||||||
email={error.data.email}
|
email={error.data.email}
|
||||||
@ -68,6 +77,16 @@ export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
|
|||||||
if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
|
if (error.status === 403 && error.data.type === 'embed-waiting-for-turn') {
|
||||||
return <EmbedDocumentWaitingForTurn />;
|
return <EmbedDocumentWaitingForTurn />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// !: Not used at the moment, may be removed in the future.
|
||||||
|
if (error.status === 403 && error.data.type === 'embed-document-rejected') {
|
||||||
|
return <EmbedDocumentRejected />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// !: Not used at the moment, may be removed in the future.
|
||||||
|
if (error.status === 403 && error.data.type === 'embed-document-completed') {
|
||||||
|
return <EmbedDocumentCompleted name={error.data.name} signature={error.data.signature} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div>Not Found</div>;
|
return <div>Not Found</div>;
|
||||||
|
|||||||
332
apps/remix/app/routes/embed+/_v0+/direct.$token.tsx
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import { data } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getEnvelopeForDirectTemplateSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-direct-template-signing';
|
||||||
|
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||||
|
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
|
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
||||||
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
||||||
|
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
||||||
|
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 { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import type { Route } from './+types/direct.$token';
|
||||||
|
|
||||||
|
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
if (!params.token) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.token;
|
||||||
|
|
||||||
|
const template = await getTemplateByDirectLinkToken({
|
||||||
|
token,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
// `template.directLink` is always available but we're doing this to
|
||||||
|
// satisfy the type checker.
|
||||||
|
if (!template || !template.directLink) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
||||||
|
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
|
||||||
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
|
// TODO: adopter or is an enterprise user.
|
||||||
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: template.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
||||||
|
match(auth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
||||||
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||||
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
returnTo: `/embed/direct/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { directTemplateRecipientId } = template.directLink;
|
||||||
|
|
||||||
|
const recipient = template.recipients.find(
|
||||||
|
(recipient) => recipient.id === directTemplateRecipientId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
template,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
if (!params.token) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.token;
|
||||||
|
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const envelopeForSigning = await getEnvelopeForDirectTemplateSigning({
|
||||||
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
})
|
||||||
|
.then((envelopeForSigning) => {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
...envelopeForSigning,
|
||||||
|
} as const;
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
const error = AppError.parseError(e);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: false,
|
||||||
|
...requiredAccessData,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelopeForSigning.isDocumentAccessValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: envelopeForSigning.recipientEmail,
|
||||||
|
returnTo: `/embed/direct/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { envelope, recipient } = envelopeForSigning;
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||||
|
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
|
||||||
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: envelope.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||||
|
match(accesssAuth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||||
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
||||||
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email || recipient.email,
|
||||||
|
returnTo: `/embed/direct/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
envelopeForSigning,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||||
|
const { token } = loaderArgs.params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not efficient but works for now until we remove v1.
|
||||||
|
const foundDirectLink = await prisma.templateDirectLink.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
envelope: {
|
||||||
|
select: {
|
||||||
|
internalVersion: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!foundDirectLink) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundDirectLink.envelope.internalVersion === 2) {
|
||||||
|
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 2,
|
||||||
|
payload: payloadV2,
|
||||||
|
} as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 1,
|
||||||
|
payload: payloadV1,
|
||||||
|
} as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmbedDirectTemplatePage() {
|
||||||
|
const { version, payload } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
if (version === 1) {
|
||||||
|
return <EmbedDirectTemplatePageV1 data={payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EmbedDirectTemplatePageV2 data={payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmbedDirectTemplatePageV1 = ({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
||||||
|
}) => {
|
||||||
|
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
||||||
|
data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSigningProvider
|
||||||
|
email={user?.email}
|
||||||
|
fullName={user?.name}
|
||||||
|
signature={user?.signature}
|
||||||
|
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={template.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<DocumentSigningRecipientProvider recipient={recipient}>
|
||||||
|
<EmbedDirectTemplateClientPage
|
||||||
|
token={token}
|
||||||
|
envelopeId={template.envelopeId}
|
||||||
|
updatedAt={template.updatedAt}
|
||||||
|
envelopeItems={template.envelopeItems}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
metadata={template.templateMeta}
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
||||||
|
/>
|
||||||
|
</DocumentSigningRecipientProvider>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</DocumentSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmbedDirectTemplatePageV2 = ({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
||||||
|
}) => {
|
||||||
|
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
||||||
|
|
||||||
|
const { envelope, recipient } = envelopeForSigning;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnvelopeSigningProvider
|
||||||
|
envelopeData={envelopeForSigning}
|
||||||
|
email={user?.email}
|
||||||
|
fullName={user?.name}
|
||||||
|
signature={user?.signature}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={envelope.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
isDirectTemplate={true}
|
||||||
|
>
|
||||||
|
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
|
||||||
|
<EmbedSignDocumentV2ClientPage
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
/>
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</EnvelopeSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,138 +0,0 @@
|
|||||||
import { data } from 'react-router';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
import { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
|
||||||
import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token';
|
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
|
||||||
|
|
||||||
import { EmbedDirectTemplateClientPage } from '~/components/embed/embed-direct-template-client-page';
|
|
||||||
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 { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|
||||||
|
|
||||||
import type { Route } from './+types/direct.$url';
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
if (!params.url) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = params.url;
|
|
||||||
|
|
||||||
const template = await getTemplateByDirectLinkToken({
|
|
||||||
token,
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
// `template.directLink` is always available but we're doing this to
|
|
||||||
// satisfy the type checker.
|
|
||||||
if (!template || !template.directLink) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: template.teamId });
|
|
||||||
|
|
||||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
|
||||||
|
|
||||||
// TODO: Make this more robust, we need to ensure the owner is either
|
|
||||||
// TODO: the member of a team that has an active subscription, is an early
|
|
||||||
// TODO: adopter or is an enterprise user.
|
|
||||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-paywall',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { user } = await getOptionalSession(request);
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
|
||||||
documentAuth: template.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) =>
|
|
||||||
match(auth)
|
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => !!user)
|
|
||||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links
|
|
||||||
.exhaustive(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
email: user?.email,
|
|
||||||
returnTo: `/embed/direct/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { directTemplateRecipientId } = template.directLink;
|
|
||||||
|
|
||||||
const recipient = template.recipients.find(
|
|
||||||
(recipient) => recipient.id === directTemplateRecipientId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!recipient) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = template.fields.filter((field) => field.recipientId === directTemplateRecipientId);
|
|
||||||
|
|
||||||
return superLoaderJson({
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
template,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmbedDirectTemplatePage() {
|
|
||||||
const { token, user, template, recipient, fields, hidePoweredBy, allowEmbedSigningWhitelabel } =
|
|
||||||
useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentSigningProvider
|
|
||||||
email={user?.email}
|
|
||||||
fullName={user?.name}
|
|
||||||
signature={user?.signature}
|
|
||||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={template.templateMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={template.templateMeta?.drawSignatureEnabled}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
|
||||||
documentAuthOptions={template.authOptions}
|
|
||||||
recipient={recipient}
|
|
||||||
user={user}
|
|
||||||
>
|
|
||||||
<DocumentSigningRecipientProvider recipient={recipient}>
|
|
||||||
<EmbedDirectTemplateClientPage
|
|
||||||
token={token}
|
|
||||||
envelopeId={template.envelopeId}
|
|
||||||
updatedAt={template.updatedAt}
|
|
||||||
documentData={template.templateDocumentData}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
metadata={template.templateMeta}
|
|
||||||
hidePoweredBy={hidePoweredBy}
|
|
||||||
allowWhiteLabelling={allowEmbedSigningWhitelabel}
|
|
||||||
/>
|
|
||||||
</DocumentSigningRecipientProvider>
|
|
||||||
</DocumentSigningAuthProvider>
|
|
||||||
</DocumentSigningProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
394
apps/remix/app/routes/embed+/_v0+/sign.$token.tsx
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
import { RecipientRole } from '@prisma/client';
|
||||||
|
import { data } from 'react-router';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
|
import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
||||||
|
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||||
|
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 { getEnvelopeForRecipientSigning } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
|
||||||
|
import { getEnvelopeRequiredAccessData } from '@documenso/lib/server-only/envelope/get-envelope-required-access-data';
|
||||||
|
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 { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
||||||
|
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
||||||
|
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||||
|
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
||||||
|
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||||
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { EmbedSignDocumentV1ClientPage } from '~/components/embed/embed-document-signing-page-v1';
|
||||||
|
import { EmbedSignDocumentV2ClientPage } from '~/components/embed/embed-document-signing-page-v2';
|
||||||
|
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
||||||
|
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
||||||
|
import { EnvelopeSigningProvider } from '~/components/general/document-signing/envelope-signing-provider';
|
||||||
|
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
||||||
|
|
||||||
|
import { getOptionalLoaderContext } from '../../../../server/utils/get-loader-session';
|
||||||
|
import type { Route } from './+types/sign.$token';
|
||||||
|
|
||||||
|
async function handleV1Loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { requestMetadata } = getOptionalLoaderContext();
|
||||||
|
|
||||||
|
if (!params.token) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.token;
|
||||||
|
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const [document, fields, recipient, completedFields] = await Promise.all([
|
||||||
|
getDocumentAndSenderByToken({
|
||||||
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
requireAccessAuth: false,
|
||||||
|
}).catch(() => null),
|
||||||
|
getFieldsForToken({ token }),
|
||||||
|
getRecipientByToken({ token }).catch(() => null),
|
||||||
|
getCompletedFieldsForToken({ token }).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// `document.directLink` is always available but we're doing this to
|
||||||
|
// satisfy the type checker.
|
||||||
|
if (!document || !recipient) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
||||||
|
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
|
||||||
|
// TODO: Make this more robust, we need to ensure the owner is either
|
||||||
|
// TODO: the member of a team that has an active subscription, is an early
|
||||||
|
// TODO: adopter or is an enterprise user.
|
||||||
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: document.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||||
|
match(accesssAuth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||||
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
||||||
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email || recipient.email,
|
||||||
|
returnTo: `/embed/sign/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
||||||
|
|
||||||
|
if (!isRecipientsTurnToSign) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-waiting-for-turn',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewedDocument({
|
||||||
|
token,
|
||||||
|
requestMetadata,
|
||||||
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRecipients =
|
||||||
|
recipient.role === RecipientRole.ASSISTANT
|
||||||
|
? await getRecipientsForAssistant({
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
allRecipients,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
completedFields,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleV2Loader({ params, request }: Route.LoaderArgs) {
|
||||||
|
const { requestMetadata } = getOptionalLoaderContext();
|
||||||
|
|
||||||
|
if (!params.token) {
|
||||||
|
throw new Response('Not found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = params.token;
|
||||||
|
|
||||||
|
const { user } = await getOptionalSession(request);
|
||||||
|
|
||||||
|
const envelopeForSigning = await getEnvelopeForRecipientSigning({
|
||||||
|
token,
|
||||||
|
userId: user?.id,
|
||||||
|
})
|
||||||
|
.then((envelopeForSigning) => {
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: true,
|
||||||
|
...envelopeForSigning,
|
||||||
|
} as const;
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
const error = AppError.parseError(e);
|
||||||
|
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
const requiredAccessData = await getEnvelopeRequiredAccessData({ token });
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDocumentAccessValid: false,
|
||||||
|
...requiredAccessData,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelopeForSigning.isDocumentAccessValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: envelopeForSigning.recipientEmail,
|
||||||
|
returnTo: `/embed/sign/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { envelope, recipient, isRecipientsTurn } = envelopeForSigning;
|
||||||
|
|
||||||
|
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: envelope.teamId });
|
||||||
|
|
||||||
|
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
||||||
|
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
||||||
|
|
||||||
|
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-paywall',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecipientsTurn) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-waiting-for-turn',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||||
|
documentAuth: envelope.authOptions,
|
||||||
|
recipientAuth: recipient.authOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
||||||
|
match(accesssAuth)
|
||||||
|
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
||||||
|
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true)
|
||||||
|
.exhaustive(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isAccessAuthValid) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
type: 'embed-authentication-required',
|
||||||
|
email: user?.email || recipient.email,
|
||||||
|
returnTo: `/embed/sign/${token}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewedDocument({
|
||||||
|
token,
|
||||||
|
requestMetadata,
|
||||||
|
recipientAccessAuth: derivedRecipientAccessAuth,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
envelopeForSigning,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader(loaderArgs: Route.LoaderArgs) {
|
||||||
|
const { token } = loaderArgs.params;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not efficient but works for now until we remove v1.
|
||||||
|
const foundRecipient = await prisma.recipient.findFirst({
|
||||||
|
where: {
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
envelope: {
|
||||||
|
select: {
|
||||||
|
internalVersion: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!foundRecipient) {
|
||||||
|
throw new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundRecipient.envelope.internalVersion === 2) {
|
||||||
|
const payloadV2 = await handleV2Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 2,
|
||||||
|
payload: payloadV2,
|
||||||
|
} as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadV1 = await handleV1Loader(loaderArgs);
|
||||||
|
|
||||||
|
return superLoaderJson({
|
||||||
|
version: 1,
|
||||||
|
payload: payloadV1,
|
||||||
|
} as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmbedSignDocumentPage() {
|
||||||
|
const { version, payload } = useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
if (version === 1) {
|
||||||
|
return <EmbedSignDocumentPageV1 data={payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EmbedSignDocumentPageV2 data={payload} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmbedSignDocumentPageV1 = ({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Awaited<ReturnType<typeof handleV1Loader>>;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
document,
|
||||||
|
allRecipients,
|
||||||
|
recipient,
|
||||||
|
fields,
|
||||||
|
completedFields,
|
||||||
|
hidePoweredBy,
|
||||||
|
allowEmbedSigningWhitelabel,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocumentSigningProvider
|
||||||
|
email={recipient.email}
|
||||||
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
|
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||||
|
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||||
|
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={document.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<EmbedSignDocumentV1ClientPage
|
||||||
|
token={token}
|
||||||
|
documentId={document.id}
|
||||||
|
envelopeId={document.envelopeId}
|
||||||
|
envelopeItems={document.envelopeItems}
|
||||||
|
recipient={recipient}
|
||||||
|
fields={fields}
|
||||||
|
completedFields={completedFields}
|
||||||
|
metadata={document.documentMeta}
|
||||||
|
isCompleted={isDocumentCompleted(document.status)}
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
allRecipients={allRecipients}
|
||||||
|
/>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</DocumentSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmbedSignDocumentPageV2 = ({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: Awaited<ReturnType<typeof handleV2Loader>>;
|
||||||
|
}) => {
|
||||||
|
const { token, user, envelopeForSigning, hidePoweredBy, allowEmbedSigningWhitelabel } = data;
|
||||||
|
|
||||||
|
const { envelope, recipient } = envelopeForSigning;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnvelopeSigningProvider
|
||||||
|
envelopeData={envelopeForSigning}
|
||||||
|
email={recipient.email}
|
||||||
|
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
||||||
|
signature={user?.email === recipient.email ? user?.signature : undefined}
|
||||||
|
>
|
||||||
|
<DocumentSigningAuthProvider
|
||||||
|
documentAuthOptions={envelope.authOptions}
|
||||||
|
recipient={recipient}
|
||||||
|
user={user}
|
||||||
|
>
|
||||||
|
<EnvelopeRenderProvider envelope={envelope} token={token}>
|
||||||
|
<EmbedSignDocumentV2ClientPage
|
||||||
|
hidePoweredBy={hidePoweredBy}
|
||||||
|
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
||||||
|
/>
|
||||||
|
</EnvelopeRenderProvider>
|
||||||
|
</DocumentSigningAuthProvider>
|
||||||
|
</EnvelopeSigningProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,181 +0,0 @@
|
|||||||
import { RecipientRole } from '@prisma/client';
|
|
||||||
import { data } from 'react-router';
|
|
||||||
import { getOptionalLoaderContext } from 'server/utils/get-loader-session';
|
|
||||||
import { match } from 'ts-pattern';
|
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
|
||||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
|
||||||
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 { getOrganisationClaimByTeamId } from '@documenso/lib/server-only/organisation/get-organisation-claims';
|
|
||||||
import { getIsRecipientsTurnToSign } from '@documenso/lib/server-only/recipient/get-is-recipient-turn';
|
|
||||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
|
||||||
import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant';
|
|
||||||
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
|
|
||||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
|
||||||
|
|
||||||
import { EmbedSignDocumentClientPage } from '~/components/embed/embed-document-signing-page';
|
|
||||||
import { DocumentSigningAuthProvider } from '~/components/general/document-signing/document-signing-auth-provider';
|
|
||||||
import { DocumentSigningProvider } from '~/components/general/document-signing/document-signing-provider';
|
|
||||||
import { superLoaderJson, useSuperLoaderData } from '~/utils/super-json-loader';
|
|
||||||
|
|
||||||
import type { Route } from './+types/sign.$url';
|
|
||||||
|
|
||||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
|
||||||
const { requestMetadata } = getOptionalLoaderContext();
|
|
||||||
|
|
||||||
if (!params.url) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = params.url;
|
|
||||||
|
|
||||||
const { user } = await getOptionalSession(request);
|
|
||||||
|
|
||||||
const [document, fields, recipient, completedFields] = await Promise.all([
|
|
||||||
getDocumentAndSenderByToken({
|
|
||||||
token,
|
|
||||||
userId: user?.id,
|
|
||||||
requireAccessAuth: false,
|
|
||||||
}).catch(() => null),
|
|
||||||
getFieldsForToken({ token }),
|
|
||||||
getRecipientByToken({ token }).catch(() => null),
|
|
||||||
getCompletedFieldsForToken({ token }).catch(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// `document.directLink` is always available but we're doing this to
|
|
||||||
// satisfy the type checker.
|
|
||||||
if (!document || !recipient) {
|
|
||||||
throw new Response('Not found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const organisationClaim = await getOrganisationClaimByTeamId({ teamId: document.teamId });
|
|
||||||
|
|
||||||
const allowEmbedSigningWhitelabel = organisationClaim.flags.embedSigningWhiteLabel;
|
|
||||||
const hidePoweredBy = organisationClaim.flags.hidePoweredBy;
|
|
||||||
|
|
||||||
// TODO: Make this more robust, we need to ensure the owner is either
|
|
||||||
// TODO: the member of a team that has an active subscription, is an early
|
|
||||||
// TODO: adopter or is an enterprise user.
|
|
||||||
if (IS_BILLING_ENABLED() && !organisationClaim.flags.embedSigning) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-paywall',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
|
||||||
documentAuth: document.authOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) =>
|
|
||||||
match(accesssAuth)
|
|
||||||
.with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email)
|
|
||||||
.with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement
|
|
||||||
.exhaustive(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAccessAuthValid) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-authentication-required',
|
|
||||||
email: user?.email || recipient.email,
|
|
||||||
returnTo: `/embed/sign/${token}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRecipientsTurnToSign = await getIsRecipientsTurnToSign({ token });
|
|
||||||
|
|
||||||
if (!isRecipientsTurnToSign) {
|
|
||||||
throw data(
|
|
||||||
{
|
|
||||||
type: 'embed-waiting-for-turn',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await viewedDocument({
|
|
||||||
token,
|
|
||||||
requestMetadata,
|
|
||||||
recipientAccessAuth: derivedRecipientAccessAuth,
|
|
||||||
});
|
|
||||||
|
|
||||||
const allRecipients =
|
|
||||||
recipient.role === RecipientRole.ASSISTANT
|
|
||||||
? await getRecipientsForAssistant({
|
|
||||||
token,
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return superLoaderJson({
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
document,
|
|
||||||
allRecipients,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
completedFields,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EmbedSignDocumentPage() {
|
|
||||||
const {
|
|
||||||
token,
|
|
||||||
user,
|
|
||||||
document,
|
|
||||||
allRecipients,
|
|
||||||
recipient,
|
|
||||||
fields,
|
|
||||||
completedFields,
|
|
||||||
hidePoweredBy,
|
|
||||||
allowEmbedSigningWhitelabel,
|
|
||||||
} = useSuperLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DocumentSigningProvider
|
|
||||||
email={recipient.email}
|
|
||||||
fullName={user?.email === recipient.email ? user?.name : recipient.name}
|
|
||||||
signature={user?.email === recipient.email ? user?.signature : undefined}
|
|
||||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
|
||||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
|
||||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
|
||||||
>
|
|
||||||
<DocumentSigningAuthProvider
|
|
||||||
documentAuthOptions={document.authOptions}
|
|
||||||
recipient={recipient}
|
|
||||||
user={user}
|
|
||||||
>
|
|
||||||
<EmbedSignDocumentClientPage
|
|
||||||
token={token}
|
|
||||||
documentId={document.id}
|
|
||||||
envelopeId={document.envelopeId}
|
|
||||||
documentData={document.documentData}
|
|
||||||
recipient={recipient}
|
|
||||||
fields={fields}
|
|
||||||
completedFields={completedFields}
|
|
||||||
metadata={document.documentMeta}
|
|
||||||
isCompleted={isDocumentCompleted(document.status)}
|
|
||||||
hidePoweredBy={hidePoweredBy}
|
|
||||||
allowWhitelabelling={allowEmbedSigningWhitelabel}
|
|
||||||
allRecipients={allRecipients}
|
|
||||||
/>
|
|
||||||
</DocumentSigningAuthProvider>
|
|
||||||
</DocumentSigningProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -67,6 +67,7 @@ export async function loader({ request }: Route.LoaderArgs) {
|
|||||||
export default function MultisignPage() {
|
export default function MultisignPage() {
|
||||||
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
|
||||||
useSuperLoaderData<typeof loader>();
|
useSuperLoaderData<typeof loader>();
|
||||||
|
|
||||||
const revalidator = useRevalidator();
|
const revalidator = useRevalidator();
|
||||||
|
|
||||||
const [selectedDocument, setSelectedDocument] = useState<
|
const [selectedDocument, setSelectedDocument] = useState<
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@epic-web/remember": "^1.1.0",
|
"@epic-web/remember": "^1.1.0",
|
||||||
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@hono/trpc-server": "^0.3.4",
|
"@hono/trpc-server": "^0.3.4",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
@ -103,5 +104,5 @@
|
|||||||
"vite-plugin-babel-macros": "^1.0.6",
|
"vite-plugin-babel-macros": "^1.0.6",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"version": "1.13.1"
|
"version": "2.0.0"
|
||||||
}
|
}
|
||||||
192
apps/remix/server/api/download/download.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { sValidator } from '@hono/standard-validator';
|
||||||
|
import { EnvelopeType } from '@prisma/client';
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
|
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||||
|
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import type { HonoEnv } from '../../router';
|
||||||
|
import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
|
||||||
|
import {
|
||||||
|
ZDownloadDocumentRequestParamsSchema,
|
||||||
|
ZDownloadEnvelopeItemRequestParamsSchema,
|
||||||
|
} from './download.types';
|
||||||
|
|
||||||
|
export const downloadRoute = new Hono<HonoEnv>()
|
||||||
|
/**
|
||||||
|
* Download an envelope item by its ID.
|
||||||
|
* Requires API key authentication via Authorization header.
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
'/envelopeItem/:envelopeItemId/download',
|
||||||
|
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
|
||||||
|
async (c) => {
|
||||||
|
const logger = c.get('logger');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { envelopeItemId, version } = c.req.valid('param');
|
||||||
|
const authorizationHeader = c.req.header('authorization');
|
||||||
|
|
||||||
|
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||||
|
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'API token was not provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiToken = await getApiTokenByToken({ token });
|
||||||
|
|
||||||
|
if (apiToken.user.disabled) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'User is disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
auth: 'api',
|
||||||
|
source: 'apiV2',
|
||||||
|
path: c.req.path,
|
||||||
|
userId: apiToken.user.id,
|
||||||
|
apiTokenId: apiToken.id,
|
||||||
|
envelopeItemId,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findFirst({
|
||||||
|
where: {
|
||||||
|
id: envelopeItemId,
|
||||||
|
envelope: {
|
||||||
|
team: buildTeamWhereQuery({ teamId: apiToken.teamId, userId: apiToken.user.id }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
envelope: true,
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelopeItem) {
|
||||||
|
return c.json({ error: 'Envelope item not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelopeItem.documentData) {
|
||||||
|
return c.json({ error: 'Document data not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleEnvelopeItemFileRequest({
|
||||||
|
title: envelopeItem.title,
|
||||||
|
status: envelopeItem.envelope.status,
|
||||||
|
documentData: envelopeItem.documentData,
|
||||||
|
version: version || 'signed',
|
||||||
|
isDownload: true,
|
||||||
|
context: c,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
return c.json({ error: error.message }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: error.message }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* Download a document by its ID.
|
||||||
|
* Requires API key authentication via Authorization header.
|
||||||
|
*/
|
||||||
|
.get(
|
||||||
|
'/document/:documentId/download',
|
||||||
|
sValidator('param', ZDownloadDocumentRequestParamsSchema),
|
||||||
|
async (c) => {
|
||||||
|
const logger = c.get('logger');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { documentId, version } = c.req.valid('param');
|
||||||
|
const authorizationHeader = c.req.header('authorization');
|
||||||
|
|
||||||
|
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
|
||||||
|
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'API token was not provided',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiToken = await getApiTokenByToken({ token });
|
||||||
|
|
||||||
|
if (apiToken.user.disabled) {
|
||||||
|
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||||
|
message: 'User is disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info({
|
||||||
|
auth: 'api',
|
||||||
|
source: 'apiV2',
|
||||||
|
path: c.req.path,
|
||||||
|
userId: apiToken.user.id,
|
||||||
|
apiTokenId: apiToken.id,
|
||||||
|
documentId,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await getEnvelopeById({
|
||||||
|
id: {
|
||||||
|
type: 'documentId',
|
||||||
|
id: documentId,
|
||||||
|
},
|
||||||
|
type: EnvelopeType.DOCUMENT,
|
||||||
|
userId: apiToken.user.id,
|
||||||
|
teamId: apiToken.teamId,
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
return c.json({ error: 'Document not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first envelope item (documents have exactly one)
|
||||||
|
const [envelopeItem] = envelope.envelopeItems;
|
||||||
|
|
||||||
|
if (!envelopeItem) {
|
||||||
|
return c.json({ error: 'Document item not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!envelopeItem.documentData) {
|
||||||
|
return c.json({ error: 'Document data not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleEnvelopeItemFileRequest({
|
||||||
|
title: envelopeItem.title,
|
||||||
|
status: envelope.status,
|
||||||
|
documentData: envelopeItem.documentData,
|
||||||
|
version: version || 'signed',
|
||||||
|
isDownload: true,
|
||||||
|
context: c,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
if (error.code === AppErrorCode.UNAUTHORIZED) {
|
||||||
|
return c.json({ error: error.message }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: error.message }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ error: 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
29
apps/remix/server/api/download/download.types.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZDownloadEnvelopeItemRequestParamsSchema = z.object({
|
||||||
|
envelopeItemId: z.string().describe('The ID of the envelope item to download.'),
|
||||||
|
version: z
|
||||||
|
.enum(['original', 'signed'])
|
||||||
|
.optional()
|
||||||
|
.default('signed')
|
||||||
|
.describe(
|
||||||
|
'The version of the envelope item to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDownloadEnvelopeItemRequestParams = z.infer<
|
||||||
|
typeof ZDownloadEnvelopeItemRequestParamsSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const ZDownloadDocumentRequestParamsSchema = z.object({
|
||||||
|
documentId: z.coerce.number().describe('The ID of the document to download.'),
|
||||||
|
version: z
|
||||||
|
.enum(['original', 'signed'])
|
||||||
|
.optional()
|
||||||
|
.default('signed')
|
||||||
|
.describe(
|
||||||
|
'The version of the document to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TDownloadDocumentRequestParams = z.infer<typeof ZDownloadDocumentRequestParamsSchema>;
|
||||||
@ -4,7 +4,7 @@ import { type Context } from 'hono';
|
|||||||
import { sha256 } from '@documenso/lib/universal/crypto';
|
import { sha256 } from '@documenso/lib/universal/crypto';
|
||||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||||
|
|
||||||
import type { HonoEnv } from '../router';
|
import type { HonoEnv } from '../../router';
|
||||||
|
|
||||||
type HandleEnvelopeItemFileRequestOptions = {
|
type HandleEnvelopeItemFileRequestOptions = {
|
||||||
title: string;
|
title: string;
|
||||||
@ -52,7 +52,6 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.header('Content-Type', 'application/pdf');
|
c.header('Content-Type', 'application/pdf');
|
||||||
c.header('Content-Length', file.length.toString());
|
|
||||||
c.header('ETag', etag);
|
c.header('ETag', etag);
|
||||||
|
|
||||||
if (!isDownload) {
|
if (!isDownload) {
|
||||||
@ -60,7 +59,7 @@ export const handleEnvelopeItemFileRequest = async ({
|
|||||||
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
c.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
} else {
|
} else {
|
||||||
// Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates.
|
// Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates.
|
||||||
c.header('Cache-Control', 'public, max-age=60, must-revalidate');
|
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import { sValidator } from '@hono/standard-validator';
|
import { sValidator } from '@hono/standard-validator';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
|
||||||
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||||
@ -9,7 +10,7 @@ import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/
|
|||||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { HonoEnv } from '../router';
|
import type { HonoEnv } from '../../router';
|
||||||
import { handleEnvelopeItemFileRequest } from './files.helpers';
|
import { handleEnvelopeItemFileRequest } from './files.helpers';
|
||||||
import {
|
import {
|
||||||
type TGetPresignedPostUrlResponse,
|
type TGetPresignedPostUrlResponse,
|
||||||
@ -206,17 +207,28 @@ export const filesRoute = new Hono<HonoEnv>()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const { token, envelopeItemId } = c.req.valid('param');
|
const { token, envelopeItemId } = c.req.valid('param');
|
||||||
|
|
||||||
const envelopeItem = await prisma.envelopeItem.findFirst({
|
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
|
||||||
where: {
|
id: envelopeItemId,
|
||||||
id: envelopeItemId,
|
envelope: {
|
||||||
envelope: {
|
recipients: {
|
||||||
recipients: {
|
some: {
|
||||||
some: {
|
token,
|
||||||
token,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token.startsWith('qr_')) {
|
||||||
|
envelopeWhereQuery = {
|
||||||
|
id: envelopeItemId,
|
||||||
|
envelope: {
|
||||||
|
qrToken: token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findUnique({
|
||||||
|
where: envelopeWhereQuery,
|
||||||
include: {
|
include: {
|
||||||
envelope: true,
|
envelope: true,
|
||||||
documentData: true,
|
documentData: true,
|
||||||
@ -247,17 +259,28 @@ export const filesRoute = new Hono<HonoEnv>()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const { token, envelopeItemId, version } = c.req.valid('param');
|
const { token, envelopeItemId, version } = c.req.valid('param');
|
||||||
|
|
||||||
const envelopeItem = await prisma.envelopeItem.findFirst({
|
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
|
||||||
where: {
|
id: envelopeItemId,
|
||||||
id: envelopeItemId,
|
envelope: {
|
||||||
envelope: {
|
recipients: {
|
||||||
recipients: {
|
some: {
|
||||||
some: {
|
token,
|
||||||
token,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token.startsWith('qr_')) {
|
||||||
|
envelopeWhereQuery = {
|
||||||
|
id: envelopeItemId,
|
||||||
|
envelope: {
|
||||||
|
qrToken: token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelopeItem = await prisma.envelopeItem.findUnique({
|
||||||
|
where: envelopeWhereQuery,
|
||||||
include: {
|
include: {
|
||||||
envelope: true,
|
envelope: true,
|
||||||
documentData: true,
|
documentData: true,
|
||||||
@ -14,7 +14,8 @@ import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
|
|||||||
import { logger } from '@documenso/lib/utils/logger';
|
import { logger } from '@documenso/lib/utils/logger';
|
||||||
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
import { openApiDocument } from '@documenso/trpc/server/open-api';
|
||||||
|
|
||||||
import { filesRoute } from './api/files';
|
import { downloadRoute } from './api/download/download';
|
||||||
|
import { filesRoute } from './api/files/files';
|
||||||
import { type AppContext, appContext } from './context';
|
import { type AppContext, appContext } from './context';
|
||||||
import { appMiddleware } from './middleware';
|
import { appMiddleware } from './middleware';
|
||||||
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
||||||
@ -92,6 +93,8 @@ app.use('/api/trpc/*', reactRouterTrpcServer);
|
|||||||
// Unstable API server routes. Order matters for these two.
|
// Unstable API server routes. Order matters for these two.
|
||||||
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||||
app.use(`${API_V2_URL}/*`, cors());
|
app.use(`${API_V2_URL}/*`, cors());
|
||||||
|
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
|
||||||
|
app.route(`${API_V2_URL}`, downloadRoute);
|
||||||
app.use(`${API_V2_URL}/*`, async (c) =>
|
app.use(`${API_V2_URL}/*`, async (c) =>
|
||||||
openApiTrpcServerHandler(c, {
|
openApiTrpcServerHandler(c, {
|
||||||
isBeta: false,
|
isBeta: false,
|
||||||
@ -101,6 +104,8 @@ app.use(`${API_V2_URL}/*`, async (c) =>
|
|||||||
// Unstable API server routes. Order matters for these two.
|
// Unstable API server routes. Order matters for these two.
|
||||||
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
app.get(`${API_V2_BETA_URL}/openapi.json`, (c) => c.json(openApiDocument));
|
||||||
app.use(`${API_V2_BETA_URL}/*`, cors());
|
app.use(`${API_V2_BETA_URL}/*`, cors());
|
||||||
|
// Shadows the download routes that tRPC defines since tRPC-to-openapi doesn't support their return types.
|
||||||
|
app.route(`${API_V2_BETA_URL}`, downloadRoute);
|
||||||
app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
app.use(`${API_V2_BETA_URL}/*`, async (c) =>
|
||||||
openApiTrpcServerHandler(c, {
|
openApiTrpcServerHandler(c, {
|
||||||
isBeta: true,
|
isBeta: true,
|
||||||
|
|||||||
199
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.13.1",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@documenso/root",
|
"name": "@documenso/root",
|
||||||
"version": "1.13.1",
|
"version": "2.0.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*"
|
||||||
@ -19,6 +19,7 @@
|
|||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
|
"pdf2json": "^4.0.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
@ -45,6 +46,7 @@
|
|||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prisma": "^6.18.0",
|
"prisma": "^6.18.0",
|
||||||
"prisma-extension-kysely": "^3.0.0",
|
"prisma-extension-kysely": "^3.0.0",
|
||||||
|
"prisma-json-types-generator": "^3.6.2",
|
||||||
"prisma-kysely": "^1.8.0",
|
"prisma-kysely": "^1.8.0",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"superjson": "^2.2.5",
|
"superjson": "^2.2.5",
|
||||||
@ -99,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"apps/remix": {
|
"apps/remix": {
|
||||||
"name": "@documenso/remix",
|
"name": "@documenso/remix",
|
||||||
"version": "1.13.1",
|
"version": "2.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cantoo/pdf-lib": "^2.5.2",
|
"@cantoo/pdf-lib": "^2.5.2",
|
||||||
"@documenso/api": "*",
|
"@documenso/api": "*",
|
||||||
@ -112,6 +114,7 @@
|
|||||||
"@documenso/trpc": "*",
|
"@documenso/trpc": "*",
|
||||||
"@documenso/ui": "*",
|
"@documenso/ui": "*",
|
||||||
"@epic-web/remember": "^1.1.0",
|
"@epic-web/remember": "^1.1.0",
|
||||||
|
"@faker-js/faker": "^10.1.0",
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@hono/trpc-server": "^0.3.4",
|
"@hono/trpc-server": "^0.3.4",
|
||||||
"@hookform/resolvers": "^3.1.0",
|
"@hookform/resolvers": "^3.1.0",
|
||||||
@ -3511,6 +3514,22 @@
|
|||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@faker-js/faker": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fakerjs"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
|
||||||
|
"npm": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
|
||||||
@ -7599,6 +7618,18 @@
|
|||||||
"empathic": "2.0.0"
|
"empathic": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@prisma/debug": {
|
||||||
|
"version": "6.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
|
||||||
|
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/@prisma/dmmf": {
|
||||||
|
"version": "6.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.18.0.tgz",
|
||||||
|
"integrity": "sha512-x0ItbLDxAnciEMFnGUm90Bkpzx2ja5Lp8Lz+9UkepIClZOMC4WvtMUHaMMfCFqfoBb+KUbxa/xV+FJ6EAaw4wQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@prisma/engines": {
|
"node_modules/@prisma/engines": {
|
||||||
"version": "6.18.0",
|
"version": "6.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz",
|
||||||
@ -7618,12 +7649,6 @@
|
|||||||
"integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==",
|
"integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/engines/node_modules/@prisma/debug": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/fetch-engine": {
|
"node_modules/@prisma/fetch-engine": {
|
||||||
"version": "6.18.0",
|
"version": "6.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz",
|
||||||
@ -7635,10 +7660,10 @@
|
|||||||
"@prisma/get-platform": "6.18.0"
|
"@prisma/get-platform": "6.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": {
|
"node_modules/@prisma/generator": {
|
||||||
"version": "6.18.0",
|
"version": "6.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.18.0.tgz",
|
||||||
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
|
"integrity": "sha512-8Sz9z8d/X/42uL07qYF4no2hnSSMPk8g6+w0zpINCN7lvnILby5b56xA0uPKRFfR3Wfn3NtFLnyEpMQE9fXeeg==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/generator-helper": {
|
"node_modules/@prisma/generator-helper": {
|
||||||
@ -7719,12 +7744,6 @@
|
|||||||
"@prisma/debug": "6.18.0"
|
"@prisma/debug": "6.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/get-platform/node_modules/@prisma/debug": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/internals": {
|
"node_modules/@prisma/internals": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-5.3.1.tgz",
|
||||||
@ -27470,6 +27489,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdf2json": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdf2json/-/pdf2json-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-WkezNsLK8sGpuFC7+PPP0DsXROwdoOxmXPBTtUWWkCwCi/Vi97MRC52Ly6FWIJjOKIywpm/L2oaUgSrmtU+7ZQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"pdf2json": "bin/pdf2json.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pdfjs-dist": {
|
"node_modules/pdfjs-dist": {
|
||||||
"version": "3.11.174",
|
"version": "3.11.174",
|
||||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
|
||||||
@ -28646,6 +28677,54 @@
|
|||||||
"@prisma/client": "latest"
|
"@prisma/client": "latest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prisma-json-types-generator": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/generator-helper": "^6.16.1",
|
||||||
|
"semver": "^7.7.2",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"prisma-json-types-generator": "index.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/arthurfiorette/prisma-json-types-generator?sponsor=1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@prisma/client": "^6.14",
|
||||||
|
"prisma": "^6.14",
|
||||||
|
"typescript": "^5.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prisma-json-types-generator/node_modules/@prisma/generator-helper": {
|
||||||
|
"version": "6.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.18.0.tgz",
|
||||||
|
"integrity": "sha512-kmlCDRRewLwBkHpkAjzyuNHD5ISlDLzUTcTsZbwmjDilQVt/S72xvvCAa+hxY16APTkqbtDYn3p7zL/XFO+C0A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/debug": "6.18.0",
|
||||||
|
"@prisma/dmmf": "6.18.0",
|
||||||
|
"@prisma/generator": "6.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prisma-json-types-generator/node_modules/semver": {
|
||||||
|
"version": "7.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||||
|
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prisma-kysely": {
|
"node_modules/prisma-kysely": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-1.8.0.tgz",
|
||||||
@ -36456,24 +36535,6 @@
|
|||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod-prisma-types/node_modules/@prisma/debug": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/zod-prisma-types/node_modules/@prisma/dmmf": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-x0ItbLDxAnciEMFnGUm90Bkpzx2ja5Lp8Lz+9UkepIClZOMC4WvtMUHaMMfCFqfoBb+KUbxa/xV+FJ6EAaw4wQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/zod-prisma-types/node_modules/@prisma/generator": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-8Sz9z8d/X/42uL07qYF4no2hnSSMPk8g6+w0zpINCN7lvnILby5b56xA0uPKRFfR3Wfn3NtFLnyEpMQE9fXeeg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/zod-prisma-types/node_modules/@prisma/generator-helper": {
|
"node_modules/zod-prisma-types/node_modules/@prisma/generator-helper": {
|
||||||
"version": "6.18.0",
|
"version": "6.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.18.0.tgz",
|
||||||
@ -37130,72 +37191,6 @@
|
|||||||
"typescript": "5.6.2"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/prisma/node_modules/@prisma/debug": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"packages/prisma/node_modules/@prisma/dmmf": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-x0ItbLDxAnciEMFnGUm90Bkpzx2ja5Lp8Lz+9UkepIClZOMC4WvtMUHaMMfCFqfoBb+KUbxa/xV+FJ6EAaw4wQ==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"packages/prisma/node_modules/@prisma/generator": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-8Sz9z8d/X/42uL07qYF4no2hnSSMPk8g6+w0zpINCN7lvnILby5b56xA0uPKRFfR3Wfn3NtFLnyEpMQE9fXeeg==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"packages/prisma/node_modules/@prisma/generator-helper": {
|
|
||||||
"version": "6.18.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.18.0.tgz",
|
|
||||||
"integrity": "sha512-kmlCDRRewLwBkHpkAjzyuNHD5ISlDLzUTcTsZbwmjDilQVt/S72xvvCAa+hxY16APTkqbtDYn3p7zL/XFO+C0A==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/debug": "6.18.0",
|
|
||||||
"@prisma/dmmf": "6.18.0",
|
|
||||||
"@prisma/generator": "6.18.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/prisma/node_modules/prisma-json-types-generator": {
|
|
||||||
"version": "3.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
|
|
||||||
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@prisma/generator-helper": "^6.16.1",
|
|
||||||
"semver": "^7.7.2",
|
|
||||||
"tslib": "^2.8.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"prisma-json-types-generator": "index.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/arthurfiorette/prisma-json-types-generator?sponsor=1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@prisma/client": "^6.14",
|
|
||||||
"prisma": "^6.14",
|
|
||||||
"typescript": "^5.9.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/prisma/node_modules/semver": {
|
|
||||||
"version": "7.7.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
|
||||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/prisma/node_modules/ts-pattern": {
|
"packages/prisma/node_modules/ts-pattern": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.7.1.tgz",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.13.1",
|
"version": "2.0.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"dev": "turbo run dev --filter=@documenso/remix",
|
"dev": "turbo run dev --filter=@documenso/remix",
|
||||||
@ -56,6 +56,7 @@
|
|||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prisma": "^6.18.0",
|
"prisma": "^6.18.0",
|
||||||
"prisma-extension-kysely": "^3.0.0",
|
"prisma-extension-kysely": "^3.0.0",
|
||||||
|
"prisma-json-types-generator": "^3.6.2",
|
||||||
"prisma-kysely": "^1.8.0",
|
"prisma-kysely": "^1.8.0",
|
||||||
"rimraf": "^5.0.1",
|
"rimraf": "^5.0.1",
|
||||||
"turbo": "^1.9.3",
|
"turbo": "^1.9.3",
|
||||||
@ -84,6 +85,7 @@
|
|||||||
"inngest-cli": "^0.29.1",
|
"inngest-cli": "^0.29.1",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"mupdf": "^1.0.0",
|
"mupdf": "^1.0.0",
|
||||||
|
"pdf2json": "^4.0.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"typescript": "5.6.2",
|
"typescript": "5.6.2",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
|
|||||||
@ -18,7 +18,7 @@ import {
|
|||||||
RecipientRole,
|
RecipientRole,
|
||||||
} from '@documenso/prisma/client';
|
} from '@documenso/prisma/client';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
import type { TCreateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||||
import type {
|
import type {
|
||||||
TCreateEnvelopePayload,
|
TCreateEnvelopePayload,
|
||||||
TCreateEnvelopeResponse,
|
TCreateEnvelopeResponse,
|
||||||
@ -403,28 +403,20 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
expect(unauthRequest.status()).toBe(404);
|
expect(unauthRequest.status()).toBe(404);
|
||||||
|
|
||||||
// Step 2: Create second envelope item via API
|
// Step 2: Create second envelope item via API
|
||||||
// Todo: Envelopes - Use API Route
|
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
|
||||||
const fieldMetaDocumentData = await prisma.documentData.create({
|
|
||||||
data: {
|
|
||||||
type: 'BYTES_64',
|
|
||||||
data: fieldMetaPdf.toString('base64'),
|
|
||||||
initialData: fieldMetaPdf.toString('base64'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const createEnvelopeItemsRequest: TCreateEnvelopeItemsRequest = {
|
|
||||||
envelopeId: createdEnvelope.id,
|
envelopeId: createdEnvelope.id,
|
||||||
data: [
|
|
||||||
{
|
|
||||||
title: 'Field Meta Test',
|
|
||||||
documentDataId: fieldMetaDocumentData.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createEnvelopeItemFormData = new FormData();
|
||||||
|
createEnvelopeItemFormData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
|
||||||
|
createEnvelopeItemFormData.append(
|
||||||
|
'files',
|
||||||
|
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
|
||||||
|
);
|
||||||
|
|
||||||
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
|
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
data: createEnvelopeItemsRequest,
|
multipart: createEnvelopeItemFormData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(createItemsRes.ok()).toBeTruthy();
|
expect(createItemsRes.ok()).toBeTruthy();
|
||||||
@ -559,5 +551,10 @@ test.describe('API V2 Envelopes', () => {
|
|||||||
);
|
);
|
||||||
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
|
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
|
||||||
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
|
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
createdEnvelopeId: finalEnvelope.id,
|
||||||
|
userEmail: userA.email,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -30,6 +30,7 @@ import {
|
|||||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
|
||||||
|
|
||||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||||
|
|
||||||
@ -3855,25 +3856,24 @@ test.describe('Document API V2', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const doc = await seedBlankDocument(userA, teamA.id);
|
const doc = await seedBlankDocument(userA, teamA.id);
|
||||||
|
|
||||||
const documentData = await prisma.documentData.create({
|
const fieldMetaPdf = fs.readFileSync(
|
||||||
data: {
|
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
|
||||||
type: 'BYTES_64',
|
);
|
||||||
data: Buffer.from('test pdf content').toString('base64'),
|
|
||||||
initialData: Buffer.from('test pdf content').toString('base64'),
|
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
|
||||||
},
|
envelopeId: doc.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
|
||||||
|
formData.append(
|
||||||
|
'files',
|
||||||
|
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
|
||||||
|
);
|
||||||
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, {
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, {
|
||||||
headers: { Authorization: `Bearer ${tokenB}` },
|
headers: { Authorization: `Bearer ${tokenB}` },
|
||||||
data: {
|
multipart: formData,
|
||||||
envelopeId: doc.id,
|
|
||||||
data: [
|
|
||||||
{
|
|
||||||
title: 'New Item',
|
|
||||||
documentDataId: documentData.id,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok()).toBeFalsy();
|
expect(res.ok()).toBeFalsy();
|
||||||
@ -3885,29 +3885,48 @@ test.describe('Document API V2', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const doc = await seedBlankDocument(userA, teamA.id);
|
const doc = await seedBlankDocument(userA, teamA.id);
|
||||||
|
|
||||||
const documentData = await prisma.documentData.create({
|
const fieldMetaPdf = fs.readFileSync(
|
||||||
data: {
|
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
|
||||||
type: 'BYTES_64',
|
);
|
||||||
data: Buffer.from('test pdf content').toString('base64'),
|
|
||||||
initialData: Buffer.from('test pdf content').toString('base64'),
|
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
|
||||||
},
|
envelopeId: doc.id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
|
||||||
|
formData.append(
|
||||||
|
'files',
|
||||||
|
new File([fieldMetaPdf], 'field-meta-1.pdf', { type: 'application/pdf' }),
|
||||||
|
);
|
||||||
|
formData.append(
|
||||||
|
'files',
|
||||||
|
new File([fieldMetaPdf], 'field-meta-2.pdf', { type: 'application/pdf' }),
|
||||||
|
);
|
||||||
|
|
||||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, {
|
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, {
|
||||||
headers: { Authorization: `Bearer ${tokenA}` },
|
headers: { Authorization: `Bearer ${tokenA}` },
|
||||||
data: {
|
multipart: formData,
|
||||||
envelopeId: doc.id,
|
});
|
||||||
data: [
|
|
||||||
{
|
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||||
title: 'New Item',
|
where: {
|
||||||
documentDataId: documentData.id,
|
id: doc.id,
|
||||||
},
|
},
|
||||||
],
|
include: {
|
||||||
|
envelopeItems: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.ok()).toBeTruthy();
|
expect(res.ok()).toBeTruthy();
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(200);
|
||||||
|
|
||||||
|
const envelopeItems = envelope.envelopeItems;
|
||||||
|
|
||||||
|
// 3 Files because seed creates one automatically.
|
||||||
|
expect(envelopeItems.length).toBe(3);
|
||||||
|
expect(envelopeItems[1].title).toBe('field-meta-1.pdf');
|
||||||
|
expect(envelopeItems[2].title).toBe('field-meta-2.pdf');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,129 @@
|
|||||||
|
import { type Page, expect, test } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
|
const SINGLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../../assets/project-proposal-single-recipient.pdf',
|
||||||
|
);
|
||||||
|
|
||||||
|
const MULTIPLE_PLACEHOLDER_PDF_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../../assets/project-proposal-multiple-fields-and-recipients.pdf',
|
||||||
|
);
|
||||||
|
|
||||||
|
const setupUserAndSignIn = async (page: Page) => {
|
||||||
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: user.email,
|
||||||
|
redirectPath: `/t/${team.url}/documents`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user, team };
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadPdfAndContinue = async (page: Page, pdfPath: string, continueClicks: number = 1) => {
|
||||||
|
const fileInput = page.locator('input[type="file"]').nth(1);
|
||||||
|
await fileInput.waitFor({ state: 'attached' });
|
||||||
|
await fileInput.setInputFiles(pdfPath);
|
||||||
|
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
for (let i = 0; i < continueClicks; i++) {
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('PDF Placeholders with single recipient', () => {
|
||||||
|
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 1);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('Email')).toHaveValue('recipient.1@documenso.com');
|
||||||
|
await expect(page.getByPlaceholder('Name')).toHaveValue('Recipient 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[AUTO_PLACING_FIELDS]: should automatically place fields from PDF placeholders', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-field-type="SIGNATURE"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="EMAIL"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[AUTO_PLACING_FIELDS]: should automatically configure fields from PDF placeholders', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, SINGLE_PLACEHOLDER_PDF_PATH, 2);
|
||||||
|
|
||||||
|
await page.getByText('Text').nth(1).click();
|
||||||
|
await page.getByRole('button', { name: 'Advanced settings' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Advanced settings' })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('div')
|
||||||
|
.filter({ hasText: /^Required field$/ })
|
||||||
|
.getByRole('switch'),
|
||||||
|
).toBeChecked();
|
||||||
|
|
||||||
|
await expect(page.getByRole('combobox')).toHaveText('Right');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('PDF Placeholders with multiple recipients', () => {
|
||||||
|
test('[AUTO_PLACING_FIELDS]: should automatically create recipients from PDF placeholders', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 1);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('signer-email-input').first()).toHaveValue(
|
||||||
|
'recipient.1@documenso.com',
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Name').first()).toHaveValue('Recipient 1');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('signer-email-input').nth(1)).toHaveValue(
|
||||||
|
'recipient.2@documenso.com',
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Name').nth(1)).toHaveValue('Recipient 2');
|
||||||
|
|
||||||
|
await expect(page.getByTestId('signer-email-input').nth(2)).toHaveValue(
|
||||||
|
'recipient.3@documenso.com',
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Name').nth(2)).toHaveValue('Recipient 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[AUTO_PLACING_FIELDS]: should automatically create fields from PDF placeholders', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await setupUserAndSignIn(page);
|
||||||
|
await uploadPdfAndContinue(page, MULTIPLE_PLACEHOLDER_PDF_PATH, 2);
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-field-type="SIGNATURE"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(1)).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="SIGNATURE"]').nth(2)).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="EMAIL"]').first()).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="EMAIL"]').nth(1)).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="NAME"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="TEXT"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-field-type="NUMBER"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -25,8 +25,7 @@ import { DocumentStatus } from '@prisma/client';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
|
||||||
|
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
|
||||||
import { seedUser } from '@documenso/prisma/seed/users';
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
@ -35,7 +34,7 @@ import { apiSignin } from '../fixtures/authentication';
|
|||||||
|
|
||||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||||
|
|
||||||
test('field placement visual regression', async ({ page }, testInfo) => {
|
test.skip('field placement visual regression', async ({ page }, testInfo) => {
|
||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
|
|
||||||
const envelope = await seedAlignmentTestDocument({
|
const envelope = await seedAlignmentTestDocument({
|
||||||
@ -95,7 +94,14 @@ test('field placement visual regression', async ({ page }, testInfo) => {
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
completedDocument.envelopeItems.map(async (item) => {
|
completedDocument.envelopeItems.map(async (item) => {
|
||||||
const pdfData = await getFile(item.documentData);
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: item,
|
||||||
|
token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||||
|
|
||||||
const loadedImages = storedImages
|
const loadedImages = storedImages
|
||||||
.filter((image) => image.includes(item.title))
|
.filter((image) => image.includes(item.title))
|
||||||
@ -103,7 +109,7 @@ test('field placement visual regression', async ({ page }, testInfo) => {
|
|||||||
|
|
||||||
await compareSignedPdfWithImages({
|
await compareSignedPdfWithImages({
|
||||||
id: item.title.replaceAll(' ', '-').toLowerCase(),
|
id: item.title.replaceAll(' ', '-').toLowerCase(),
|
||||||
pdfData,
|
pdfData: new Uint8Array(pdfData),
|
||||||
images: loadedImages,
|
images: loadedImages,
|
||||||
testInfo,
|
testInfo,
|
||||||
});
|
});
|
||||||
@ -174,9 +180,16 @@ test.skip('download envelope images', async ({ page }) => {
|
|||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
completedDocument.envelopeItems.map(async (item) => {
|
completedDocument.envelopeItems.map(async (item) => {
|
||||||
const pdfData = await getFile(item.documentData);
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: item,
|
||||||
|
token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
|
||||||
const pdfImages = await renderPdfToImage(pdfData);
|
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||||
|
|
||||||
|
const pdfImages = await renderPdfToImage(new Uint8Array(pdfData));
|
||||||
|
|
||||||
for (const [index, { image }] of pdfImages.entries()) {
|
for (const [index, { image }] of pdfImages.entries()) {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
|
|||||||
import { DocumentStatus, FieldType } from '@prisma/client';
|
import { DocumentStatus, FieldType } from '@prisma/client';
|
||||||
|
|
||||||
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
|
||||||
import { getFile } from '@documenso/lib/universal/upload/get-file';
|
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||||
@ -25,20 +25,26 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentData = await prisma.documentData
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
const documentData = await prisma.envelopeItem
|
||||||
.findFirstOrThrow({
|
.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
envelopeItem: {
|
envelopeId: document.id,
|
||||||
envelopeId: document.id,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (data) => getFile(data));
|
.then(async (data) => {
|
||||||
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: data,
|
||||||
|
token: recipient.token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||||
|
});
|
||||||
|
|
||||||
const originalPdf = await PDFDocument.load(documentData);
|
const originalPdf = await PDFDocument.load(documentData);
|
||||||
|
|
||||||
const recipient = recipients[0];
|
|
||||||
|
|
||||||
// Sign the document
|
// Sign the document
|
||||||
await page.goto(`/sign/${recipient.token}`);
|
await page.goto(`/sign/${recipient.token}`);
|
||||||
|
|
||||||
@ -78,9 +84,18 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
|
const firstDocumentData = completedDocument.envelopeItems[0];
|
||||||
|
|
||||||
const completedDocumentData = await getFile(firstDocumentData);
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: firstDocumentData,
|
||||||
|
token: recipient.token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||||
|
|
||||||
|
const completedDocumentData = new Uint8Array(pdfData);
|
||||||
|
|
||||||
// Load the PDF and check number of pages
|
// Load the PDF and check number of pages
|
||||||
const pdfDoc = await PDFDocument.load(completedDocumentData);
|
const pdfDoc = await PDFDocument.load(completedDocumentData);
|
||||||
@ -117,20 +132,26 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentData = await prisma.documentData
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
const documentData = await prisma.envelopeItem
|
||||||
.findFirstOrThrow({
|
.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
envelopeItem: {
|
envelopeId: document.id,
|
||||||
envelopeId: document.id,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (data) => getFile(data));
|
.then(async (data) => {
|
||||||
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: data,
|
||||||
|
token: recipient.token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||||
|
});
|
||||||
|
|
||||||
const originalPdf = await PDFDocument.load(documentData);
|
const originalPdf = await PDFDocument.load(documentData);
|
||||||
|
|
||||||
const recipient = recipients[0];
|
|
||||||
|
|
||||||
// Sign the document
|
// Sign the document
|
||||||
await page.goto(`/sign/${recipient.token}`);
|
await page.goto(`/sign/${recipient.token}`);
|
||||||
|
|
||||||
@ -168,9 +189,18 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
|
const firstDocumentData = completedDocument.envelopeItems[0];
|
||||||
|
|
||||||
const completedDocumentData = await getFile(firstDocumentData);
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: firstDocumentData,
|
||||||
|
token: recipient.token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||||
|
|
||||||
|
const completedDocumentData = new Uint8Array(pdfData);
|
||||||
|
|
||||||
// Load the PDF and check number of pages
|
// Load the PDF and check number of pages
|
||||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||||
@ -207,19 +237,25 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const documentData = await prisma.documentData
|
const recipient = recipients[0];
|
||||||
|
|
||||||
|
const documentData = await prisma.envelopeItem
|
||||||
.findFirstOrThrow({
|
.findFirstOrThrow({
|
||||||
where: {
|
where: {
|
||||||
envelopeItem: {
|
envelopeId: document.id,
|
||||||
envelopeId: document.id,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (data) => getFile(data));
|
.then(async (data) => {
|
||||||
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: data,
|
||||||
|
token: recipient.token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
|
||||||
|
});
|
||||||
|
|
||||||
const originalPdf = await PDFDocument.load(documentData);
|
const originalPdf = await PDFDocument.load(new Uint8Array(documentData));
|
||||||
|
|
||||||
const recipient = recipients[0];
|
|
||||||
|
|
||||||
// Sign the document
|
// Sign the document
|
||||||
await page.goto(`/sign/${recipient.token}`);
|
await page.goto(`/sign/${recipient.token}`);
|
||||||
@ -258,7 +294,16 @@ test.describe('Signing Certificate Tests', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const completedDocumentData = await getFile(completedDocument.envelopeItems[0].documentData);
|
const documentUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'download',
|
||||||
|
envelopeItem: completedDocument.envelopeItems[0],
|
||||||
|
token: recipient.token,
|
||||||
|
version: 'signed',
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedDocumentData = await fetch(documentUrl).then(
|
||||||
|
async (res) => await res.arrayBuffer(),
|
||||||
|
);
|
||||||
|
|
||||||
// Load the PDF and check number of pages
|
// Load the PDF and check number of pages
|
||||||
const completedPdf = await PDFDocument.load(completedDocumentData);
|
const completedPdf = await PDFDocument.load(completedDocumentData);
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
|
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||||
@ -12,6 +11,10 @@ import { seedUser } from '@documenso/prisma/seed/users';
|
|||||||
import { apiSignin } from '../fixtures/authentication';
|
import { apiSignin } from '../fixtures/authentication';
|
||||||
|
|
||||||
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
|
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
|
||||||
|
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(
|
||||||
|
__dirname,
|
||||||
|
'../../../../assets/field-font-alignment.pdf',
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1. Create a template with all settings filled out
|
* 1. Create a template with all settings filled out
|
||||||
@ -233,10 +236,6 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
|||||||
const { user, team } = await seedUser();
|
const { user, team } = await seedUser();
|
||||||
const template = await seedBlankTemplate(user, team.id);
|
const template = await seedBlankTemplate(user, team.id);
|
||||||
|
|
||||||
// Create a temporary PDF file for upload
|
|
||||||
|
|
||||||
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
|
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -277,7 +276,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
|
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||||
|
|
||||||
// Wait for upload to complete
|
// Wait for upload to complete
|
||||||
await expect(page.getByText('Remove')).toBeVisible();
|
await expect(page.getByText('Remove')).toBeVisible();
|
||||||
@ -314,8 +313,12 @@ test('[TEMPLATE]: should create a document from a template with custom document'
|
|||||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||||
|
|
||||||
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
||||||
expect(firstDocumentData.data).toEqual(pdfContent);
|
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||||
expect(firstDocumentData.initialData).toEqual(pdfContent);
|
// Probably need to do a pixel match
|
||||||
|
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||||
|
expect(firstDocumentData.initialData).not.toEqual(
|
||||||
|
template.envelopeItems[0].documentData.initialData,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||||
expect(firstDocumentData.data).toBeTruthy();
|
expect(firstDocumentData.data).toBeTruthy();
|
||||||
@ -336,8 +339,6 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
|||||||
|
|
||||||
const template = await seedBlankTemplate(owner, team.id);
|
const template = await seedBlankTemplate(owner, team.id);
|
||||||
|
|
||||||
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
|
|
||||||
|
|
||||||
await apiSignin({
|
await apiSignin({
|
||||||
page,
|
page,
|
||||||
email: owner.email,
|
email: owner.email,
|
||||||
@ -378,7 +379,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
|
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
|
||||||
|
|
||||||
// Wait for upload to complete
|
// Wait for upload to complete
|
||||||
await expect(page.getByText('Remove')).toBeVisible();
|
await expect(page.getByText('Remove')).toBeVisible();
|
||||||
@ -416,8 +417,12 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
|
|||||||
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
|
||||||
|
|
||||||
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
|
||||||
expect(firstDocumentData.data).toEqual(pdfContent);
|
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
|
||||||
expect(firstDocumentData.initialData).toEqual(pdfContent);
|
// Probably need to do a pixel match
|
||||||
|
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
|
||||||
|
expect(firstDocumentData.initialData).not.toEqual(
|
||||||
|
template.envelopeItems[0].documentData.initialData,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
|
||||||
expect(firstDocumentData.data).toBeTruthy();
|
expect(firstDocumentData.data).toBeTruthy();
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 117 KiB |
BIN
packages/assets/project-proposal-single-recipient.pdf
Normal file
@ -5,6 +5,7 @@ import { deleteCookie } from 'hono/cookie';
|
|||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||||
|
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
import type { OAuthClientOptions } from '../../config';
|
import type { OAuthClientOptions } from '../../config';
|
||||||
@ -177,6 +178,12 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
|
|||||||
redirectPath = '/';
|
redirectPath = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidReturnTo(redirectPath)) {
|
||||||
|
redirectPath = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectPath = normalizeReturnTo(redirectPath) || '/';
|
||||||
|
|
||||||
const tokens = await oAuthClient.validateAuthorizationCode(
|
const tokens = await oAuthClient.validateAuthorizationCode(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
code,
|
code,
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import type { DocumentData } from '@prisma/client';
|
import type { EnvelopeItem } from '@prisma/client';
|
||||||
|
|
||||||
import { getFile } from '../universal/upload/get-file';
|
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
|
||||||
import { downloadFile } from './download-file';
|
import { downloadFile } from './download-file';
|
||||||
|
|
||||||
type DocumentVersion = 'original' | 'signed';
|
type DocumentVersion = 'original' | 'signed';
|
||||||
|
|
||||||
type DownloadPDFProps = {
|
type DownloadPDFProps = {
|
||||||
documentData: DocumentData;
|
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
|
||||||
|
token: string | undefined;
|
||||||
|
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
/**
|
/**
|
||||||
* Specifies which version of the document to download.
|
* Specifies which version of the document to download.
|
||||||
@ -17,18 +19,19 @@ type DownloadPDFProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const downloadPDF = async ({
|
export const downloadPDF = async ({
|
||||||
documentData,
|
envelopeItem,
|
||||||
|
token,
|
||||||
fileName,
|
fileName,
|
||||||
version = 'signed',
|
version = 'signed',
|
||||||
}: DownloadPDFProps) => {
|
}: DownloadPDFProps) => {
|
||||||
const bytes = await getFile({
|
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||||
type: documentData.type,
|
type: 'download',
|
||||||
data: version === 'signed' ? documentData.data : documentData.initialData,
|
envelopeItem: envelopeItem,
|
||||||
|
token,
|
||||||
|
version,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = new Blob([bytes], {
|
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||||
type: 'application/pdf',
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
|
||||||
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
|
||||||
|
|||||||
@ -46,6 +46,7 @@ type EnvelopeEditorProviderValue = {
|
|||||||
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
|
||||||
|
|
||||||
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
|
||||||
|
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
|
||||||
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
|
||||||
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
|
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
|
||||||
|
|
||||||
@ -66,8 +67,6 @@ type EnvelopeEditorProviderValue = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
syncEnvelope: () => Promise<void>;
|
syncEnvelope: () => Promise<void>;
|
||||||
// refetchEnvelope: () => Promise<void>;
|
|
||||||
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EnvelopeEditorProviderProps {
|
interface EnvelopeEditorProviderProps {
|
||||||
@ -151,7 +150,7 @@ export const EnvelopeEditorProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
|
||||||
onSuccess: ({ recipients }) => {
|
onSuccess: ({ data: recipients }) => {
|
||||||
setEnvelope((prev) => ({
|
setEnvelope((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
recipients,
|
recipients,
|
||||||
@ -197,7 +196,7 @@ export const EnvelopeEditorProvider = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Insert the IDs into the local fields.
|
// Insert the IDs into the local fields.
|
||||||
envelopeFields.fields.forEach((field) => {
|
envelopeFields.data.forEach((field) => {
|
||||||
const localField = localFields.find((localField) => localField.formId === field.formId);
|
const localField = localFields.find((localField) => localField.formId === field.formId);
|
||||||
|
|
||||||
if (localField && !localField.id) {
|
if (localField && !localField.id) {
|
||||||
@ -236,6 +235,13 @@ export const EnvelopeEditorProvider = ({
|
|||||||
setEnvelopeDebounced(envelopeUpdates);
|
setEnvelopeDebounced(envelopeUpdates);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
|
||||||
|
await envelopeUpdateMutationQuery.mutateAsync({
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
...envelopeUpdates,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const getRecipientColorKey = useCallback(
|
const getRecipientColorKey = useCallback(
|
||||||
(recipientId: number) => {
|
(recipientId: number) => {
|
||||||
const recipientIndex = envelope.recipients.findIndex(
|
const recipientIndex = envelope.recipients.findIndex(
|
||||||
@ -323,6 +329,7 @@ export const EnvelopeEditorProvider = ({
|
|||||||
setLocalEnvelope,
|
setLocalEnvelope,
|
||||||
getRecipientColorKey,
|
getRecipientColorKey,
|
||||||
updateEnvelope,
|
updateEnvelope,
|
||||||
|
updateEnvelopeAsync,
|
||||||
setRecipientsDebounced,
|
setRecipientsDebounced,
|
||||||
setRecipientsAsync,
|
setRecipientsAsync,
|
||||||
editorFields,
|
editorFields,
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import type { DocumentData } from '@prisma/client';
|
import type { Field, Recipient } from '@prisma/client';
|
||||||
|
|
||||||
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
|
||||||
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
|
||||||
|
|
||||||
import type { TEnvelope } from '../../types/envelope';
|
import type { TEnvelope } from '../../types/envelope';
|
||||||
import { getFile } from '../../universal/upload/get-file';
|
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
|
||||||
|
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
|
||||||
|
|
||||||
type FileData =
|
type FileData =
|
||||||
| {
|
| {
|
||||||
@ -18,22 +19,31 @@ type FileData =
|
|||||||
status: 'loaded';
|
status: 'loaded';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EnvelopeRenderOverrideSettings = {
|
||||||
|
mode?: FieldRenderMode;
|
||||||
|
showRecipientTooltip?: boolean;
|
||||||
|
showRecipientSigningStatus?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
|
||||||
|
|
||||||
type EnvelopeRenderProviderValue = {
|
type EnvelopeRenderProviderValue = {
|
||||||
getPdfBuffer: (documentDataId: string) => FileData | null;
|
getPdfBuffer: (envelopeItemId: string) => FileData | null;
|
||||||
envelopeItems: EnvelopeRenderItem[];
|
envelopeItems: EnvelopeRenderItem[];
|
||||||
currentEnvelopeItem: EnvelopeRenderItem | null;
|
currentEnvelopeItem: EnvelopeRenderItem | null;
|
||||||
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
|
||||||
fields: TEnvelope['fields'];
|
fields: Field[];
|
||||||
|
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
|
||||||
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
getRecipientColorKey: (recipientId: number) => TRecipientColor;
|
||||||
|
|
||||||
renderError: boolean;
|
renderError: boolean;
|
||||||
setRenderError: (renderError: boolean) => void;
|
setRenderError: (renderError: boolean) => void;
|
||||||
|
overrideSettings?: EnvelopeRenderOverrideSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface EnvelopeRenderProviderProps {
|
interface EnvelopeRenderProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
||||||
envelope: Pick<TEnvelope, 'envelopeItems'>;
|
envelope: Pick<TEnvelope, 'envelopeItems'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -41,14 +51,27 @@ interface EnvelopeRenderProviderProps {
|
|||||||
*
|
*
|
||||||
* Only pass if the CustomRenderer you are passing in wants fields.
|
* Only pass if the CustomRenderer you are passing in wants fields.
|
||||||
*/
|
*/
|
||||||
fields?: TEnvelope['fields'];
|
fields?: Field[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional recipient IDs used to determine the color of the fields.
|
* Optional recipient used to determine the color of the fields and hover
|
||||||
|
* previews.
|
||||||
*
|
*
|
||||||
* Only required for generic page renderers.
|
* Only required for generic page renderers.
|
||||||
*/
|
*/
|
||||||
recipientIds?: number[];
|
recipients?: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The token to access the envelope.
|
||||||
|
*
|
||||||
|
* If not provided, it will be assumed that the current user can access the document.
|
||||||
|
*/
|
||||||
|
token: string | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom override settings for generic page renderers.
|
||||||
|
*/
|
||||||
|
overrideSettings?: EnvelopeRenderOverrideSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
|
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
|
||||||
@ -70,7 +93,9 @@ export const EnvelopeRenderProvider = ({
|
|||||||
children,
|
children,
|
||||||
envelope,
|
envelope,
|
||||||
fields,
|
fields,
|
||||||
recipientIds = [],
|
token,
|
||||||
|
recipients = [],
|
||||||
|
overrideSettings,
|
||||||
}: EnvelopeRenderProviderProps) => {
|
}: EnvelopeRenderProviderProps) => {
|
||||||
// Indexed by documentDataId.
|
// Indexed by documentDataId.
|
||||||
const [files, setFiles] = useState<Record<string, FileData>>({});
|
const [files, setFiles] = useState<Record<string, FileData>>({});
|
||||||
@ -84,27 +109,35 @@ export const EnvelopeRenderProvider = ({
|
|||||||
[envelope.envelopeItems],
|
[envelope.envelopeItems],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadEnvelopeItemPdfFile = async (documentData: DocumentData) => {
|
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
|
||||||
if (files[documentData.id]?.status === 'loading') {
|
if (files[envelopeItem.id]?.status === 'loading') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!files[documentData.id]) {
|
if (!files[envelopeItem.id]) {
|
||||||
setFiles((prev) => ({
|
setFiles((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[documentData.id]: {
|
[envelopeItem.id]: {
|
||||||
status: 'loading',
|
status: 'loading',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = await getFile(documentData);
|
const downloadUrl = getEnvelopeItemPdfUrl({
|
||||||
|
type: 'view',
|
||||||
|
envelopeItem: envelopeItem,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
|
||||||
|
|
||||||
|
const file = await blob.arrayBuffer();
|
||||||
|
|
||||||
setFiles((prev) => ({
|
setFiles((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[documentData.id]: {
|
[envelopeItem.id]: {
|
||||||
file,
|
file: new Uint8Array(file),
|
||||||
status: 'loaded',
|
status: 'loaded',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -113,7 +146,7 @@ export const EnvelopeRenderProvider = ({
|
|||||||
|
|
||||||
setFiles((prev) => ({
|
setFiles((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[documentData.id]: {
|
[envelopeItem.id]: {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@ -121,8 +154,8 @@ export const EnvelopeRenderProvider = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getPdfBuffer = useCallback(
|
const getPdfBuffer = useCallback(
|
||||||
(documentDataId: string) => {
|
(envelopeItemId: string) => {
|
||||||
return files[documentDataId] || null;
|
return files[envelopeItemId] || null;
|
||||||
},
|
},
|
||||||
[files],
|
[files],
|
||||||
);
|
);
|
||||||
@ -142,13 +175,18 @@ export const EnvelopeRenderProvider = ({
|
|||||||
|
|
||||||
// Look for any missing pdf files and load them.
|
// Look for any missing pdf files and load them.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.documentDataId]);
|
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
|
||||||
|
|
||||||
for (const item of missingFiles) {
|
for (const item of missingFiles) {
|
||||||
void loadEnvelopeItemPdfFile(item.documentData);
|
void loadEnvelopeItemPdfFile(item);
|
||||||
}
|
}
|
||||||
}, [envelope.envelopeItems]);
|
}, [envelope.envelopeItems]);
|
||||||
|
|
||||||
|
const recipientIds = useMemo(
|
||||||
|
() => recipients.map((recipient) => recipient.id).sort(),
|
||||||
|
[recipients],
|
||||||
|
);
|
||||||
|
|
||||||
const getRecipientColorKey = useCallback(
|
const getRecipientColorKey = useCallback(
|
||||||
(recipientId: number) => {
|
(recipientId: number) => {
|
||||||
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
|
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
|
||||||
@ -168,9 +206,11 @@ export const EnvelopeRenderProvider = ({
|
|||||||
currentEnvelopeItem: currentItem,
|
currentEnvelopeItem: currentItem,
|
||||||
setCurrentEnvelopeItem,
|
setCurrentEnvelopeItem,
|
||||||
fields: fields ?? [],
|
fields: fields ?? [],
|
||||||
|
recipients,
|
||||||
getRecipientColorKey,
|
getRecipientColorKey,
|
||||||
renderError,
|
renderError,
|
||||||
setRenderError,
|
setRenderError,
|
||||||
|
overrideSettings,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -78,6 +78,14 @@ export const adminFindDocuments = async ({
|
|||||||
url: true,
|
url: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
envelopeItems: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
envelopeId: true,
|
||||||
|
title: true,
|
||||||
|
order: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.envelope.count({
|
prisma.envelope.count({
|
||||||
|
|||||||
@ -248,6 +248,14 @@ export const findDocuments = async ({
|
|||||||
url: true,
|
url: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
envelopeItems: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
envelopeId: true,
|
||||||
|
title: true,
|
||||||
|
order: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.envelope.count({
|
prisma.envelope.count({
|
||||||
|
|||||||
@ -92,6 +92,10 @@ export const getDocumentAndSenderByToken = async ({
|
|||||||
},
|
},
|
||||||
envelopeItems: {
|
envelopeItems: {
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
order: true,
|
||||||
|
envelopeId: true,
|
||||||
documentData: true,
|
documentData: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -63,5 +63,8 @@ export const getDocumentWithDetailsById = async ({
|
|||||||
documentId: legacyDocumentId,
|
documentId: legacyDocumentId,
|
||||||
password: null,
|
password: null,
|
||||||
},
|
},
|
||||||
|
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
|
||||||
|
...envelopeItem,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,11 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import {
|
||||||
|
extractPlaceholdersFromPDF,
|
||||||
|
insertFieldsFromPlaceholdersInPDF,
|
||||||
|
removePlaceholdersFromPDF,
|
||||||
|
} from '@documenso/lib/server-only/pdf/auto-place-fields';
|
||||||
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
import { normalizePdf as makeNormalizedPdf } from '@documenso/lib/server-only/pdf/normalize-pdf';
|
||||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||||
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
@ -34,6 +39,7 @@ import { getFileServerSide } from '../../universal/upload/get-file.server';
|
|||||||
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
|
||||||
import { extractDerivedDocumentMeta } from '../../utils/document';
|
import { extractDerivedDocumentMeta } from '../../utils/document';
|
||||||
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
import { createDocumentAuthOptions, createRecipientAuthOptions } from '../../utils/document-auth';
|
||||||
|
import type { EnvelopeIdOptions } from '../../utils/envelope';
|
||||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||||
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
import { incrementDocumentId, incrementTemplateId } from '../envelope/increment-id';
|
||||||
import { getTeamSettings } from '../team/get-team-settings';
|
import { getTeamSettings } from '../team/get-team-settings';
|
||||||
@ -256,7 +262,7 @@ export const createEnvelope = async ({
|
|||||||
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
? await incrementDocumentId().then((v) => v.formattedDocumentId)
|
||||||
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
: await incrementTemplateId().then((v) => v.formattedTemplateId);
|
||||||
|
|
||||||
return await prisma.$transaction(async (tx) => {
|
const createdEnvelope = await prisma.$transaction(async (tx) => {
|
||||||
const envelope = await tx.envelope.create({
|
const envelope = await tx.envelope.create({
|
||||||
data: {
|
data: {
|
||||||
id: prefixedId('envelope'),
|
id: prefixedId('envelope'),
|
||||||
@ -376,8 +382,12 @@ export const createEnvelope = async ({
|
|||||||
recipients: true,
|
recipients: true,
|
||||||
fields: true,
|
fields: true,
|
||||||
folder: true,
|
folder: true,
|
||||||
envelopeItems: true,
|
|
||||||
envelopeAttachments: true,
|
envelopeAttachments: true,
|
||||||
|
envelopeItems: {
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -413,4 +423,74 @@ export const createEnvelope = async ({
|
|||||||
|
|
||||||
return createdEnvelope;
|
return createdEnvelope;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
for (const envelopeItem of createdEnvelope.envelopeItems) {
|
||||||
|
const buffer = await getFileServerSide(envelopeItem.documentData);
|
||||||
|
const pdfToProcess = Buffer.from(buffer);
|
||||||
|
|
||||||
|
const envelopeOptions: EnvelopeIdOptions = {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: createdEnvelope.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholders = await extractPlaceholdersFromPDF(pdfToProcess);
|
||||||
|
|
||||||
|
if (placeholders.length > 0) {
|
||||||
|
const pdfWithoutPlaceholders = await removePlaceholdersFromPDF(pdfToProcess);
|
||||||
|
|
||||||
|
await insertFieldsFromPlaceholdersInPDF(
|
||||||
|
pdfWithoutPlaceholders,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
envelopeOptions,
|
||||||
|
requestMetadata,
|
||||||
|
envelopeItem.id,
|
||||||
|
createdEnvelope.recipients,
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleToUse = envelopeItem.title || title;
|
||||||
|
const fileName = titleToUse.endsWith('.pdf') ? titleToUse : `${titleToUse}.pdf`;
|
||||||
|
|
||||||
|
const newDocumentData = await putPdfFileServerSide({
|
||||||
|
name: fileName,
|
||||||
|
type: 'application/pdf',
|
||||||
|
arrayBuffer: async () => Promise.resolve(pdfWithoutPlaceholders),
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.envelopeItem.update({
|
||||||
|
where: {
|
||||||
|
id: envelopeItem.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
documentDataId: newDocumentData.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalEnvelope = await prisma.envelope.findFirst({
|
||||||
|
where: {
|
||||||
|
id: createdEnvelope.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
documentMeta: true,
|
||||||
|
recipients: true,
|
||||||
|
fields: true,
|
||||||
|
folder: true,
|
||||||
|
envelopeAttachments: true,
|
||||||
|
envelopeItems: {
|
||||||
|
include: {
|
||||||
|
documentData: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!finalEnvelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalEnvelope;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -143,7 +143,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
|
|||||||
envelope,
|
envelope,
|
||||||
recipient: {
|
recipient: {
|
||||||
...recipient,
|
...recipient,
|
||||||
token: envelope.directLink?.token || '',
|
directToken: envelope.directLink?.token || '',
|
||||||
},
|
},
|
||||||
recipientSignature: null,
|
recipientSignature: null,
|
||||||
isRecipientsTurn: true,
|
isRecipientsTurn: true,
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } fro
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
|
|
||||||
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
|
||||||
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
|
||||||
import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
|
import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
|
||||||
@ -72,20 +71,11 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||||||
.array(),
|
.array(),
|
||||||
|
|
||||||
envelopeItems: EnvelopeItemSchema.pick({
|
envelopeItems: EnvelopeItemSchema.pick({
|
||||||
|
envelopeId: true,
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
title: true,
|
||||||
documentDataId: true,
|
|
||||||
order: true,
|
order: true,
|
||||||
})
|
}).array(),
|
||||||
.extend({
|
|
||||||
documentData: DocumentDataSchema.pick({
|
|
||||||
type: true,
|
|
||||||
id: true,
|
|
||||||
data: true,
|
|
||||||
initialData: true,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.array(),
|
|
||||||
|
|
||||||
team: TeamSchema.pick({
|
team: TeamSchema.pick({
|
||||||
id: true,
|
id: true,
|
||||||
@ -117,6 +107,7 @@ export const ZEnvelopeForSigningResponse = z.object({
|
|||||||
signingOrder: true,
|
signingOrder: true,
|
||||||
rejectionReason: true,
|
rejectionReason: true,
|
||||||
}).extend({
|
}).extend({
|
||||||
|
directToken: z.string().nullish(),
|
||||||
fields: ZFieldSchema.omit({
|
fields: ZFieldSchema.omit({
|
||||||
documentId: true,
|
documentId: true,
|
||||||
templateId: true,
|
templateId: true,
|
||||||
@ -199,11 +190,7 @@ export const getEnvelopeForRecipientSigning = async ({
|
|||||||
signingOrder: 'asc',
|
signingOrder: 'asc',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
envelopeItems: {
|
envelopeItems: true,
|
||||||
include: {
|
|
||||||
documentData: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
team: {
|
team: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
347
packages/lib/server-only/pdf/auto-place-fields.ts
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
import { PDFDocument, rgb } from '@cantoo/pdf-lib';
|
||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import PDFParser from 'pdf2json';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||||
|
import { createEnvelopeFields } from '@documenso/lib/server-only/field/create-envelope-fields';
|
||||||
|
import { type TFieldAndMeta, ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
|
||||||
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { getPageSize } from './get-page-size';
|
||||||
|
import {
|
||||||
|
determineRecipientsForPlaceholders,
|
||||||
|
extractRecipientPlaceholder,
|
||||||
|
findRecipientByPlaceholder,
|
||||||
|
parseFieldMetaFromPlaceholder,
|
||||||
|
parseFieldTypeFromPlaceholder,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
const PLACEHOLDER_REGEX = /{{([^}]+)}}/g;
|
||||||
|
const DEFAULT_FIELD_HEIGHT_PERCENT = 2;
|
||||||
|
const WIDTH_ADJUSTMENT_FACTOR = 0.1;
|
||||||
|
const MIN_HEIGHT_THRESHOLD = 0.01;
|
||||||
|
|
||||||
|
type TextPosition = {
|
||||||
|
text: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CharIndexMapping = {
|
||||||
|
textPositionIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlaceholderInfo = {
|
||||||
|
placeholder: string;
|
||||||
|
recipient: string;
|
||||||
|
fieldAndMeta: TFieldAndMeta;
|
||||||
|
page: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FieldToCreate = TFieldAndMeta & {
|
||||||
|
envelopeItemId?: string;
|
||||||
|
recipientId: number;
|
||||||
|
page: number;
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractPlaceholdersFromPDF = async (pdf: Buffer): Promise<PlaceholderInfo[]> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const parser = new PDFParser(null, true);
|
||||||
|
|
||||||
|
parser.on('pdfParser_dataError', (errData) => {
|
||||||
|
reject(errData);
|
||||||
|
});
|
||||||
|
|
||||||
|
parser.on('pdfParser_dataReady', (pdfData) => {
|
||||||
|
const placeholders: PlaceholderInfo[] = [];
|
||||||
|
|
||||||
|
pdfData.Pages.forEach((page, pageIndex) => {
|
||||||
|
/*
|
||||||
|
pdf2json returns the PDF page content as an array of characters.
|
||||||
|
We need to concatenate the characters to get the full text.
|
||||||
|
We also need to get the position of the text so we can place the placeholders in the correct position.
|
||||||
|
|
||||||
|
Page dimensions from PDF2JSON are in "page units" (relative coordinates)
|
||||||
|
*/
|
||||||
|
let pageText = '';
|
||||||
|
const textPositions: TextPosition[] = [];
|
||||||
|
const charIndexMappings: CharIndexMapping[] = [];
|
||||||
|
|
||||||
|
page.Texts.forEach((text) => {
|
||||||
|
/*
|
||||||
|
R is an array of objects containing each character, its position and styling information.
|
||||||
|
The decodedText stores the characters, without any other information.
|
||||||
|
|
||||||
|
textPositions stores each character and its position on the page.
|
||||||
|
*/
|
||||||
|
const decodedText = text.R.map((run) => decodeURIComponent(run.T)).join('');
|
||||||
|
|
||||||
|
/*
|
||||||
|
For each character in the decodedText, we store its position in the textPositions array.
|
||||||
|
This allows us to quickly find the position of a character in the textPositions array by its index.
|
||||||
|
*/
|
||||||
|
for (let i = 0; i < decodedText.length; i++) {
|
||||||
|
charIndexMappings.push({
|
||||||
|
textPositionIndex: textPositions.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pageText += decodedText;
|
||||||
|
|
||||||
|
textPositions.push({
|
||||||
|
text: decodedText,
|
||||||
|
x: text.x,
|
||||||
|
y: text.y,
|
||||||
|
w: text.w || 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const placeholderMatches = pageText.matchAll(PLACEHOLDER_REGEX);
|
||||||
|
|
||||||
|
/*
|
||||||
|
A placeholder match has the following format:
|
||||||
|
|
||||||
|
[
|
||||||
|
'{{fieldType,recipient,fieldMeta}}',
|
||||||
|
'fieldType,recipient,fieldMeta',
|
||||||
|
'index: <number>',
|
||||||
|
'input: <pdf-text>'
|
||||||
|
]
|
||||||
|
*/
|
||||||
|
for (const placeholderMatch of placeholderMatches) {
|
||||||
|
const placeholder = placeholderMatch[0];
|
||||||
|
const placeholderData = placeholderMatch[1].split(',').map((property) => property.trim());
|
||||||
|
|
||||||
|
const [fieldTypeString, recipient, ...fieldMetaData] = placeholderData;
|
||||||
|
|
||||||
|
const rawFieldMeta = Object.fromEntries(
|
||||||
|
fieldMetaData.map((property) => property.split('=')),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldType = parseFieldTypeFromPlaceholder(fieldTypeString);
|
||||||
|
const parsedFieldMeta = parseFieldMetaFromPlaceholder(rawFieldMeta, fieldType);
|
||||||
|
|
||||||
|
const fieldAndMeta: TFieldAndMeta = ZFieldAndMetaSchema.parse({
|
||||||
|
type: fieldType,
|
||||||
|
fieldMeta: parsedFieldMeta,
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Find the position of where the placeholder starts and ends in the text.
|
||||||
|
|
||||||
|
Then find the position of the characters in the textPositions array.
|
||||||
|
This allows us to quickly find the position of a character in the textPositions array by its index.
|
||||||
|
*/
|
||||||
|
const placeholderEndCharIndex = placeholderMatch.index + placeholder.length;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Get the index of the placeholder's first and last character in the textPositions array.
|
||||||
|
Used to retrieve the character information from the textPositions array.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
startTextPosIndex - 1
|
||||||
|
endTextPosIndex - 40
|
||||||
|
*/
|
||||||
|
const startTextPosIndex = charIndexMappings[placeholderMatch.index].textPositionIndex;
|
||||||
|
const endTextPosIndex = charIndexMappings[placeholderEndCharIndex - 1].textPositionIndex;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Get the placeholder's first and last character information from the textPositions array.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
placeholderStart = { text: '{', x: 100, y: 100, w: 100 }
|
||||||
|
placeholderEnd = { text: '}', x: 200, y: 100, w: 100 }
|
||||||
|
*/
|
||||||
|
const placeholderStart = textPositions[startTextPosIndex];
|
||||||
|
const placeholderEnd = textPositions[endTextPosIndex];
|
||||||
|
|
||||||
|
const width =
|
||||||
|
placeholderEnd.x + placeholderEnd.w * WIDTH_ADJUSTMENT_FACTOR - placeholderStart.x;
|
||||||
|
|
||||||
|
placeholders.push({
|
||||||
|
placeholder,
|
||||||
|
recipient,
|
||||||
|
fieldAndMeta,
|
||||||
|
page: pageIndex + 1,
|
||||||
|
x: placeholderStart.x,
|
||||||
|
y: placeholderStart.y,
|
||||||
|
width,
|
||||||
|
height: 1,
|
||||||
|
pageWidth: page.Width,
|
||||||
|
pageHeight: page.Height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(placeholders);
|
||||||
|
});
|
||||||
|
|
||||||
|
parser.parseBuffer(pdf);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePlaceholdersFromPDF = async (pdf: Buffer): Promise<Buffer> => {
|
||||||
|
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||||
|
|
||||||
|
const pdfDoc = await PDFDocument.load(new Uint8Array(pdf));
|
||||||
|
const pages = pdfDoc.getPages();
|
||||||
|
|
||||||
|
for (const placeholder of placeholders) {
|
||||||
|
const pageIndex = placeholder.page - 1;
|
||||||
|
const page = pages[pageIndex];
|
||||||
|
|
||||||
|
const { width: pdfLibPageWidth, height: pdfLibPageHeight } = getPageSize(page);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Convert PDF2JSON coordinates to pdf-lib coordinates:
|
||||||
|
|
||||||
|
PDF2JSON uses relative "page units":
|
||||||
|
- x, y, width, height are in page units
|
||||||
|
- Page dimensions (Width, Height) are also in page units
|
||||||
|
|
||||||
|
pdf-lib uses absolute points (1 point = 1/72 inch):
|
||||||
|
- Need to convert from page units to points
|
||||||
|
- Y-axis in pdf-lib is bottom-up (origin at bottom-left)
|
||||||
|
- Y-axis in PDF2JSON is top-down (origin at top-left)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const xPoints = (placeholder.x / placeholder.pageWidth) * pdfLibPageWidth;
|
||||||
|
const yPoints = pdfLibPageHeight - (placeholder.y / placeholder.pageHeight) * pdfLibPageHeight;
|
||||||
|
const widthPoints = (placeholder.width / placeholder.pageWidth) * pdfLibPageWidth;
|
||||||
|
const heightPoints = (placeholder.height / placeholder.pageHeight) * pdfLibPageHeight;
|
||||||
|
|
||||||
|
page.drawRectangle({
|
||||||
|
x: xPoints,
|
||||||
|
y: yPoints - heightPoints, // Adjust for height since y is at baseline
|
||||||
|
width: widthPoints,
|
||||||
|
height: heightPoints,
|
||||||
|
color: rgb(1, 1, 1),
|
||||||
|
borderColor: rgb(1, 1, 1),
|
||||||
|
borderWidth: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiedPdfBytes = await pdfDoc.save();
|
||||||
|
|
||||||
|
return Buffer.from(modifiedPdfBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertFieldsFromPlaceholdersInPDF = async (
|
||||||
|
pdf: Buffer,
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
envelopeId: EnvelopeIdOptions,
|
||||||
|
requestMetadata: ApiRequestMetadata,
|
||||||
|
envelopeItemId?: string,
|
||||||
|
recipients?: Pick<Recipient, 'id' | 'email'>[],
|
||||||
|
): Promise<Buffer> => {
|
||||||
|
const placeholders = await extractPlaceholdersFromPDF(pdf);
|
||||||
|
|
||||||
|
if (placeholders.length === 0) {
|
||||||
|
return pdf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A structure that maps the recipient index to the recipient name.
|
||||||
|
Example: 1 => 'Recipient 1'
|
||||||
|
*/
|
||||||
|
const recipientPlaceholders = new Map<number, string>();
|
||||||
|
|
||||||
|
for (const placeholder of placeholders) {
|
||||||
|
const { name, recipientIndex } = extractRecipientPlaceholder(placeholder.recipient);
|
||||||
|
|
||||||
|
recipientPlaceholders.set(recipientIndex, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { envelopeWhereInput } = await getEnvelopeWhereInput({
|
||||||
|
id: envelopeId,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
type: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await prisma.envelope.findFirst({
|
||||||
|
where: envelopeWhereInput,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
type: true,
|
||||||
|
secondaryId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!envelope) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Envelope not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdRecipients = await determineRecipientsForPlaceholders(
|
||||||
|
recipients,
|
||||||
|
recipientPlaceholders,
|
||||||
|
envelope,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
requestMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldsToCreate: FieldToCreate[] = [];
|
||||||
|
|
||||||
|
for (const placeholder of placeholders) {
|
||||||
|
/*
|
||||||
|
Convert PDF2JSON coordinates to percentage-based coordinates (0-100)
|
||||||
|
The UI expects positionX and positionY as percentages, not absolute points
|
||||||
|
PDF2JSON uses relative coordinates: x/pageWidth and y/pageHeight give us the percentage
|
||||||
|
*/
|
||||||
|
const xPercent = (placeholder.x / placeholder.pageWidth) * 100;
|
||||||
|
const yPercent = (placeholder.y / placeholder.pageHeight) * 100;
|
||||||
|
|
||||||
|
const widthPercent = (placeholder.width / placeholder.pageWidth) * 100;
|
||||||
|
const heightPercent = (placeholder.height / placeholder.pageHeight) * 100;
|
||||||
|
|
||||||
|
const recipient = findRecipientByPlaceholder(
|
||||||
|
placeholder.recipient,
|
||||||
|
placeholder.placeholder,
|
||||||
|
recipients,
|
||||||
|
createdRecipients,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Default height percentage if too small (use 2% as a reasonable default)
|
||||||
|
const finalHeightPercent =
|
||||||
|
heightPercent > MIN_HEIGHT_THRESHOLD ? heightPercent : DEFAULT_FIELD_HEIGHT_PERCENT;
|
||||||
|
|
||||||
|
fieldsToCreate.push({
|
||||||
|
...placeholder.fieldAndMeta,
|
||||||
|
envelopeItemId,
|
||||||
|
recipientId: recipient.id,
|
||||||
|
page: placeholder.page,
|
||||||
|
positionX: xPercent,
|
||||||
|
positionY: yPercent,
|
||||||
|
width: widthPercent,
|
||||||
|
height: finalHeightPercent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await createEnvelopeFields({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
id: envelopeId,
|
||||||
|
fields: fieldsToCreate,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return pdf;
|
||||||
|
};
|
||||||
266
packages/lib/server-only/pdf/helpers.ts
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
import { FieldType } from '@prisma/client';
|
||||||
|
import { type Envelope, EnvelopeType, RecipientRole } from '@prisma/client';
|
||||||
|
import type { Recipient } from '@prisma/client';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { createEnvelopeRecipients } from '@documenso/lib/server-only/recipient/create-envelope-recipients';
|
||||||
|
import type { ApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||||
|
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||||
|
import type { EnvelopeIdOptions } from '@documenso/lib/utils/envelope';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
type RecipientPlaceholderInfo = {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
recipientIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Parse field type string to FieldType enum.
|
||||||
|
Normalizes the input (uppercase, trim) and validates it's a valid field type.
|
||||||
|
This ensures we handle case variations and whitespace, and provides clear error messages.
|
||||||
|
*/
|
||||||
|
export const parseFieldTypeFromPlaceholder = (fieldTypeString: string): FieldType => {
|
||||||
|
const normalizedType = fieldTypeString.toUpperCase().trim();
|
||||||
|
|
||||||
|
return match(normalizedType)
|
||||||
|
.with('SIGNATURE', () => FieldType.SIGNATURE)
|
||||||
|
.with('FREE_SIGNATURE', () => FieldType.FREE_SIGNATURE)
|
||||||
|
.with('INITIALS', () => FieldType.INITIALS)
|
||||||
|
.with('NAME', () => FieldType.NAME)
|
||||||
|
.with('EMAIL', () => FieldType.EMAIL)
|
||||||
|
.with('DATE', () => FieldType.DATE)
|
||||||
|
.with('TEXT', () => FieldType.TEXT)
|
||||||
|
.with('NUMBER', () => FieldType.NUMBER)
|
||||||
|
.with('RADIO', () => FieldType.RADIO)
|
||||||
|
.with('CHECKBOX', () => FieldType.CHECKBOX)
|
||||||
|
.with('DROPDOWN', () => FieldType.DROPDOWN)
|
||||||
|
.otherwise(() => {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Invalid field type: ${fieldTypeString}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Transform raw field metadata from placeholder format to schema format.
|
||||||
|
Users should provide properly capitalized property names (e.g., readOnly, fontSize, textAlign).
|
||||||
|
Converts string values to proper types (booleans, numbers).
|
||||||
|
*/
|
||||||
|
export const parseFieldMetaFromPlaceholder = (
|
||||||
|
rawFieldMeta: Record<string, string>,
|
||||||
|
fieldType: FieldType,
|
||||||
|
): Record<string, unknown> | undefined => {
|
||||||
|
if (fieldType === FieldType.SIGNATURE || fieldType === FieldType.FREE_SIGNATURE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(rawFieldMeta).length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldTypeString = String(fieldType).toLowerCase();
|
||||||
|
|
||||||
|
const parsedFieldMeta: Record<string, boolean | number | string> = {
|
||||||
|
type: fieldTypeString,
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
rawFieldMeta is an object with string keys and string values.
|
||||||
|
It contains string values because the PDF parser returns the values as strings.
|
||||||
|
|
||||||
|
E.g. { 'required': 'true', 'fontSize': '12', 'maxValue': '100', 'minValue': '0', 'characterLimit': '100' }
|
||||||
|
*/
|
||||||
|
const rawFieldMetaEntries = Object.entries(rawFieldMeta);
|
||||||
|
|
||||||
|
for (const [property, value] of rawFieldMetaEntries) {
|
||||||
|
if (property === 'readOnly' || property === 'required') {
|
||||||
|
parsedFieldMeta[property] = value === 'true';
|
||||||
|
} else if (
|
||||||
|
property === 'fontSize' ||
|
||||||
|
property === 'maxValue' ||
|
||||||
|
property === 'minValue' ||
|
||||||
|
property === 'characterLimit'
|
||||||
|
) {
|
||||||
|
const numValue = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isNaN(numValue)) {
|
||||||
|
parsedFieldMeta[property] = numValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parsedFieldMeta[property] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedFieldMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractRecipientPlaceholder = (placeholder: string): RecipientPlaceholderInfo => {
|
||||||
|
const indexMatch = placeholder.match(/^r(\d+)$/i);
|
||||||
|
|
||||||
|
if (!indexMatch) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Invalid recipient placeholder format: ${placeholder}. Expected format: r1, r2, r3, etc.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientIndex = Number(indexMatch[1]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: `recipient.${recipientIndex}@documenso.com`,
|
||||||
|
name: `Recipient ${recipientIndex}`,
|
||||||
|
recipientIndex,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Finds a recipient based on a placeholder reference.
|
||||||
|
If recipients array is provided, uses index-based matching (r1 -> recipients[0], etc.).
|
||||||
|
Otherwise, uses email-based matching from createdRecipients.
|
||||||
|
*/
|
||||||
|
export const findRecipientByPlaceholder = (
|
||||||
|
recipientPlaceholder: string,
|
||||||
|
placeholder: string,
|
||||||
|
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
|
||||||
|
createdRecipients: Pick<Recipient, 'id' | 'email'>[],
|
||||||
|
): Pick<Recipient, 'id' | 'email'> => {
|
||||||
|
if (recipients && recipients.length > 0) {
|
||||||
|
/*
|
||||||
|
Map placeholder by index: r1 -> recipients[0], r2 -> recipients[1], etc.
|
||||||
|
recipientIndex is 1-based, so we subtract 1 to get the array index.
|
||||||
|
*/
|
||||||
|
const { recipientIndex } = extractRecipientPlaceholder(recipientPlaceholder);
|
||||||
|
const recipientArrayIndex = recipientIndex - 1;
|
||||||
|
|
||||||
|
if (recipientArrayIndex < 0 || recipientArrayIndex >= recipients.length) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Recipient placeholder ${recipientPlaceholder} (index ${recipientIndex}) is out of range. Provided ${recipients.length} recipient(s).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipients[recipientArrayIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Use email-based matching for placeholder recipients.
|
||||||
|
*/
|
||||||
|
const { email } = extractRecipientPlaceholder(recipientPlaceholder);
|
||||||
|
const recipient = createdRecipients.find((r) => r.email === email);
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Could not find recipient ID for placeholder: ${placeholder}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return recipient;
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Determines the recipients to use for field creation.
|
||||||
|
If recipients are provided, uses them directly.
|
||||||
|
Otherwise, creates recipients from placeholders.
|
||||||
|
*/
|
||||||
|
export const determineRecipientsForPlaceholders = async (
|
||||||
|
recipients: Pick<Recipient, 'id' | 'email'>[] | undefined,
|
||||||
|
recipientPlaceholders: Map<number, string>,
|
||||||
|
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
requestMetadata: ApiRequestMetadata,
|
||||||
|
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
|
||||||
|
if (recipients && recipients.length > 0) {
|
||||||
|
return recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createRecipientsFromPlaceholders(
|
||||||
|
recipientPlaceholders,
|
||||||
|
envelope,
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
requestMetadata,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRecipientsFromPlaceholders = async (
|
||||||
|
recipientPlaceholders: Map<number, string>,
|
||||||
|
envelope: Pick<Envelope, 'id' | 'type' | 'secondaryId'>,
|
||||||
|
userId: number,
|
||||||
|
teamId: number,
|
||||||
|
requestMetadata: ApiRequestMetadata,
|
||||||
|
): Promise<Pick<Recipient, 'id' | 'email'>[]> => {
|
||||||
|
const recipientsToCreate = Array.from(
|
||||||
|
recipientPlaceholders.entries(),
|
||||||
|
([recipientIndex, name]) => {
|
||||||
|
return {
|
||||||
|
email: `recipient.${recipientIndex}@documenso.com`,
|
||||||
|
name,
|
||||||
|
role: RecipientRole.SIGNER,
|
||||||
|
signingOrder: recipientIndex,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingRecipients = await prisma.recipient.findMany({
|
||||||
|
where: {
|
||||||
|
envelopeId: envelope.id,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingEmails = new Set(existingRecipients.map((r) => r.email));
|
||||||
|
const recipientsToCreateFiltered = recipientsToCreate.filter(
|
||||||
|
(recipient) => !existingEmails.has(recipient.email),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recipientsToCreateFiltered.length === 0) {
|
||||||
|
return existingRecipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRecipients = await match(envelope.type)
|
||||||
|
.with(EnvelopeType.DOCUMENT, async () => {
|
||||||
|
const envelopeId: EnvelopeIdOptions = {
|
||||||
|
type: 'envelopeId',
|
||||||
|
id: envelope.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { recipients } = await createEnvelopeRecipients({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
id: envelopeId,
|
||||||
|
recipients: recipientsToCreateFiltered,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients;
|
||||||
|
})
|
||||||
|
.with(EnvelopeType.TEMPLATE, async () => {
|
||||||
|
const templateId = mapSecondaryIdToTemplateId(envelope.secondaryId ?? '');
|
||||||
|
|
||||||
|
const envelopeId: EnvelopeIdOptions = {
|
||||||
|
type: 'templateId',
|
||||||
|
id: templateId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { recipients } = await createEnvelopeRecipients({
|
||||||
|
userId,
|
||||||
|
teamId,
|
||||||
|
id: envelopeId,
|
||||||
|
recipients: recipientsToCreateFiltered,
|
||||||
|
requestMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
return recipients;
|
||||||
|
})
|
||||||
|
.otherwise(() => {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||||
|
message: `Invalid envelope type: ${envelope.type}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...existingRecipients, ...newRecipients];
|
||||||
|
};
|
||||||
@ -23,5 +23,7 @@ export const normalizePdf = async (pdf: Buffer) => {
|
|||||||
await flattenForm(pdfDoc);
|
await flattenForm(pdfDoc);
|
||||||
flattenAnnotations(pdfDoc);
|
flattenAnnotations(pdfDoc);
|
||||||
|
|
||||||
return Buffer.from(await pdfDoc.save());
|
const normalizedPdfBytes = await pdfDoc.save();
|
||||||
|
|
||||||
|
return Buffer.from(normalizedPdfBytes);
|
||||||
};
|
};
|
||||||
|
|||||||