Compare commits

...

24 Commits

Author SHA1 Message Date
ff44ffbc03 v2.0.6 2025-11-10 19:08:43 +11:00
441842d2bd fix: use correct token for embeded template files (#2160) 2025-11-10 19:08:08 +11:00
ca0b83579f fix: auto insert prefilled text and number fields (#2157) 2025-11-10 18:04:21 +11:00
6c0d1da91e fix(input): prevent mobile zoom on input focus (#2079) 2025-11-10 12:43:06 +11:00
805982f3e8 fix: envelope cc issues (#2158) 2025-11-10 11:42:57 +11:00
e2f5e570cf fix: envelope direct template (#2156) 2025-11-09 22:23:13 +11:00
9fd9613076 feat: add additional field options (#2154) 2025-11-08 23:40:03 +11:00
0977c16e33 v2.0.5 2025-11-08 16:03:59 +11:00
88d5a636c3 fix: show legacy ids on template and document view page (#2153)
<img width="557" height="455" alt="image"
src="https://github.com/user-attachments/assets/7b669f4a-c6c5-4fdc-bf10-da0def7b0b3f"
/>
2025-11-08 16:03:26 +11:00
1e6292b1d9 v2.0.4 2025-11-08 13:58:11 +11:00
d65866156d fix: remove parallel steps (#2152) 2025-11-08 13:57:26 +11:00
fe8915162f v2.0.3 2025-11-08 12:53:50 +11:00
37a2634aca feat: support optimizeParallelism for inngest jobs (#2151) 2025-11-08 12:53:13 +11:00
eff7d90f43 v2.0.2 2025-11-08 00:48:31 +11:00
db5524f8ce fix: resolve issue with sealing task on inngest (#2146)
Currently on inngest the sealing task fails during decoration stating
that it can not find the step "xxx"

My running theory is that this was due to it being a
Promise.all(map(...)) even though that isn't explicitly disallowed.

This change turns it into a for loop collecting promises to be awaited
after the fact.

Local inngest testing looks promising.
2025-11-08 00:48:13 +11:00
3d539b20ad v2.0.1 2025-11-07 23:42:03 +11:00
48626b9169 fix: support utf8 filenames download (#2145) 2025-11-07 23:41:31 +11:00
88371b665a fix: set correct envelope item cache url (#2144) 2025-11-07 16:50:58 +11:00
1650c55b19 v2.0.0 2025-11-07 15:40:24 +11:00
60d73e0921 chore: add translations (#2143)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-11-07 14:45:21 +11:00
4a779ec81e fix: handle custom org limits with member invite 2025-11-07 14:24:05 +11:00
7f19ec1265 fix: embedded direct template recipient auth 2025-11-07 14:23:46 +11:00
d6a2f5a4c9 chore: extract translations (#2070) 2025-11-07 14:20:53 +11:00
d05bfa9fed feat: add envelopes api (#2105) 2025-11-07 14:17:52 +11:00
292 changed files with 16941 additions and 6781 deletions

View File

@ -19,13 +19,15 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: number;
id: string;
token?: string;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DocumentDuplicateDialog = ({
id,
token,
open,
onOpenChange,
}: DocumentDuplicateDialogProps) => {
@ -36,27 +38,23 @@ export const DocumentDuplicateDialog = ({
const team = useCurrentTeam();
const { data: document, isLoading } = trpcReact.document.get.useQuery(
{
documentId: id,
},
{
queryHash: `document-duplicate-dialog-${id}`,
enabled: open === true,
},
);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
{
envelopeId: id,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
enabled: open,
},
);
const documentData = document?.documentData
? {
...document.documentData,
data: document.documentData.initialData,
}
: undefined;
const envelopeItems = envelopeItemsPayload?.data || [];
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateDocument, isPending: isDuplicateLoading } =
trpcReact.document.duplicate.useMutation({
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpcReact.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: _(msg`Document Duplicated`),
@ -71,7 +69,7 @@ export const DocumentDuplicateDialog = ({
const onDuplicate = async () => {
try {
await duplicateDocument({ documentId: id });
await duplicateEnvelope({ envelopeId: id });
} catch {
toast({
title: _(msg`Something went wrong`),
@ -83,14 +81,14 @@ export const DocumentDuplicateDialog = ({
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Duplicate</Trans>
</DialogTitle>
</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">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
<Trans>Loading Document...</Trans>
@ -98,7 +96,12 @@ export const DocumentDuplicateDialog = ({
</div>
) : (
<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>
)}
@ -115,8 +118,8 @@ export const DocumentDuplicateDialog = ({
<Button
type="button"
disabled={isDuplicateLoading || isLoading}
loading={isDuplicateLoading}
disabled={isDuplicating}
loading={isDuplicating}
onClick={onDuplicate}
className="flex-1"
>

View File

@ -336,7 +336,7 @@ export const EnvelopeDistributeDialog = ({
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground p-4">

View File

@ -2,11 +2,10 @@ import { useState } from 'react';
import { useLingui } 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 { downloadFile } from '@documenso/lib/client-only/download-file';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -20,9 +19,7 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
documentData: DocumentData;
};
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
type EnvelopeDownloadDialogProps = {
envelopeId: string;
@ -64,12 +61,12 @@ export const EnvelopeDownloadDialog = ({
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
const envelopeItems = envelopeItemsPayload?.data || [];
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,
@ -87,25 +84,11 @@ export const EnvelopeDownloadDialog = ({
}));
try {
const data = await getFile({
type: envelopeItem.documentData.type,
data:
version === 'signed'
? envelopeItem.documentData.data
: envelopeItem.documentData.initialData,
});
const blob = new Blob([data], {
type: 'application/pdf',
});
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
downloadFile({
filename,
data: blob,
await downloadPDF({
envelopeItem,
token,
fileName: envelopeItem.title,
version,
});
setIsDownloadingState((prev) => ({
@ -146,7 +129,7 @@ export const EnvelopeDownloadDialog = ({
<div className="flex flex-col gap-4">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 2 }).map((_, index) => (
{Array.from({ length: 1 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
@ -175,6 +158,7 @@ export const EnvelopeDownloadDialog = ({
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>PDF Document</Trans>

View File

@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({
onSuccess: async ({ duplicatedEnvelopeId }) => {
onSuccess: async ({ id }) => {
toast({
title: t`Envelope Duplicated`,
description: t`Your envelope has been successfully duplicated.`,
@ -55,7 +55,7 @@ export const EnvelopeDuplicateDialog = ({
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
await navigate(`${path}/${id}/edit`);
setOpen(false);
},
});

View File

@ -185,6 +185,10 @@ export const OrganisationMemberInviteDialog = ({
return 'form';
}
if (fullOrganisation.members.length < fullOrganisation.organisationClaim.memberCount) {
return 'form';
}
// This is probably going to screw us over in the future.
if (fullOrganisation.organisationClaim.originalSubscriptionClaimId !== INTERNAL_CLAIM_ID.TEAM) {
return 'alert';

View File

@ -1,5 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
@ -28,49 +27,71 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
let schema = z.coerce.number({
invalid_type_error: msg`Please enter a valid number`.id,
});
const { numberFormat, minValue, maxValue } = fieldMeta;
if (typeof minValue === 'number') {
schema = schema.min(minValue);
}
if (typeof maxValue === 'number') {
schema = schema.max(maxValue);
}
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (!foundRegex) {
return schema;
}
return schema.refine(
(value) => {
return foundRegex.test(value.toString());
},
{
message: msg`Number needs to be formatted as ${numberFormat}`.id,
},
);
}
return schema;
};
export type SignFieldNumberDialogProps = {
fieldMeta: TNumberFieldMeta;
};
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | null>(
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, string | null>(
({ call, fieldMeta }) => {
const { t } = useLingui();
// Needs to be inside dialog for translation purposes.
const createNumberFieldSchema = (fieldMeta: TNumberFieldMeta) => {
const { numberFormat, minValue, maxValue } = fieldMeta;
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (foundRegex) {
return z.string().refine(
(value) => {
return foundRegex.test(value.toString());
},
{
message: t`Number needs to be formatted as ${numberFormat}`,
},
);
}
}
// Not gong to work with min/max numbers + number format
// Since currently doesn't work in V1 going to ignore for now.
return z.string().superRefine((value, ctx) => {
const isValidNumber = /^[0-9,.]+$/.test(value.toString());
if (!isValidNumber) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t`Please enter a valid number`,
});
return;
}
if (typeof minValue === 'number' && parseFloat(value) < minValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: minValue,
inclusive: true,
type: 'number',
});
return;
}
if (typeof maxValue === 'number' && parseFloat(value) > maxValue) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
maximum: maxValue,
inclusive: true,
type: 'number',
});
return;
}
});
};
const ZSignFieldNumberFormSchema = z.object({
number: createNumberFieldSchema(fieldMeta),
});

View File

@ -7,9 +7,9 @@ import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@ -54,13 +54,17 @@ export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) =>
setIsUploadingFile(true);
try {
const response = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
const payload = {
title: file.name,
templateDocumentDataId: response.id,
folderId: folderId,
});
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({
title: _(msg`Template document uploaded`),

View File

@ -143,7 +143,7 @@ export function TemplateUseDialog({
},
);
const envelopeItems = response?.envelopeItems ?? [];
const envelopeItems = response?.data ?? [];
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();

View File

@ -4,6 +4,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
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 { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
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 { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
@ -83,21 +83,14 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => {
if (documentData) {
return documentData;
return documentData.data;
}
if (!configData.documentData) {
return null;
}
const data = base64.encode(configData.documentData?.data);
return {
id: 'preview',
type: 'BYTES_64',
data,
initialData: data,
} satisfies DocumentData;
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const recipients = useMemo(() => {
@ -541,7 +534,15 @@ export const ConfigureFieldsView = ({
<Form {...form}>
{normalizedDocumentData && (
<div>
<PDFViewer documentData={normalizedDocumentData} />
<PDFViewer
overrideData={normalizedDocumentData}
envelopeItem={{
id: '',
envelopeId: '',
}}
token={undefined}
version="signed"
/>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}

View File

@ -9,6 +9,7 @@ export type EmbedAuthenticationRequiredProps = {
email?: string;
returnTo: string;
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
oidcProviderLabel?: string;
};
@ -17,6 +18,7 @@ export const EmbedAuthenticationRequired = ({
email,
returnTo,
// isGoogleSSOEnabled,
// isMicrosoftSSOEnabled,
// isOIDCSSOEnabled,
// oidcProviderLabel,
}: EmbedAuthenticationRequiredProps) => {
@ -37,6 +39,7 @@ export const EmbedAuthenticationRequired = ({
<SignInForm
// Embed currently not supported.
// isGoogleSSOEnabled={isGoogleSSOEnabled}
// isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
// isOIDCSSOEnabled={isOIDCSSOEnabled}
// oidcProviderLabel={oidcProviderLabel}
className="mt-4"

View File

@ -3,8 +3,8 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import type { DocumentMeta, EnvelopeItem, Recipient, Signature } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
@ -47,7 +47,7 @@ export type EmbedDirectTemplateClientPageProps = {
token: string;
envelopeId: string;
updatedAt: Date;
documentData: DocumentData;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | null;
@ -59,7 +59,7 @@ export const EmbedDirectTemplateClientPage = ({
token,
envelopeId,
updatedAt,
documentData,
envelopeItems,
recipient,
fields,
metadata,
@ -335,7 +335,9 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
<div className="flex-1">
<PDFViewer
documentData={documentData}
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>

View File

@ -3,14 +3,8 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta } from '@prisma/client';
import {
type DocumentData,
type Field,
FieldType,
RecipientRole,
SigningStatus,
} from '@prisma/client';
import type { DocumentMeta, EnvelopeItem } from '@prisma/client';
import { type Field, FieldType, RecipientRole, SigningStatus } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
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 { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentClientPageProps = {
export type EmbedSignDocumentV1ClientPageProps = {
token: string;
documentId: number;
envelopeId: string;
documentData: DocumentData;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
@ -61,11 +55,11 @@ export type EmbedSignDocumentClientPageProps = {
allRecipients?: RecipientWithFields[];
};
export const EmbedSignDocumentClientPage = ({
export const EmbedSignDocumentV1ClientPage = ({
token,
documentId,
envelopeId,
documentData,
envelopeItems,
recipient,
fields,
completedFields,
@ -74,7 +68,7 @@ export const EmbedSignDocumentClientPage = ({
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
}: EmbedSignDocumentClientPageProps) => {
}: EmbedSignDocumentV1ClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -293,7 +287,9 @@ export const EmbedSignDocumentClientPage = ({
{/* Viewer */}
<div className="embed--DocumentViewer flex-1">
<PDFViewer
documentData={documentData}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>

View File

@ -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>
);
};

View 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>
);
};

View File

@ -226,7 +226,9 @@ export const MultiSignDocumentSigningView = ({
})}
>
<PDFViewer
documentData={document.documentData}
envelopeItem={document.envelopeItems[0]}
token={token}
version="signed"
onDocumentLoad={() => {
setHasDocumentLoaded(true);
onDocumentReady?.();

View File

@ -1,14 +1,14 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGlobalSettings } from '@prisma/client';
import { Loader } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { getFile } from '@documenso/lib/universal/upload/get-file';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -29,6 +29,8 @@ import {
} from '@documenso/ui/primitives/select';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useOptionalCurrentTeam } from '~/providers/team';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_FILE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@ -68,6 +70,9 @@ export function BrandingPreferencesForm({
}: BrandingPreferencesFormProps) {
const { t } = useLingui();
const team = useOptionalCurrentTeam();
const organisation = useCurrentOrganisation();
const [previewUrl, setPreviewUrl] = useState<string>('');
const [hasLoadedPreview, setHasLoadedPreview] = useState(false);
@ -88,14 +93,13 @@ export function BrandingPreferencesForm({
const file = JSON.parse(settings.brandingLogo);
if ('type' in file && 'data' in file) {
void getFile(file).then((binaryData) => {
const objectUrl = URL.createObjectURL(new Blob([binaryData]));
const logoUrl =
context === 'Team'
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/team/${team?.id}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/branding/logo/organisation/${organisation?.id}`;
setPreviewUrl(objectUrl);
setHasLoadedPreview(true);
});
return;
setPreviewUrl(logoUrl);
setHasLoadedPreview(true);
}
}

View File

@ -7,6 +7,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TDateFieldMeta as DateFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZDateFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
@ -39,7 +40,7 @@ export const EditorFieldDateForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -7,6 +7,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TEmailFieldMeta as EmailFieldMeta,
FIELD_DEFAULT_GENERIC_ALIGN,
ZEmailFieldMeta,
} from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
@ -39,7 +40,7 @@ export const EditorFieldEmailForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -3,6 +3,10 @@ import { useEffect } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Control, useFormContext } from 'react-hook-form';
import { FIELD_MIN_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LINE_HEIGHT } from '@documenso/lib/types/field-meta';
import { FIELD_MIN_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { FIELD_MAX_LETTER_SPACING } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
@ -107,6 +111,119 @@ export const EditorGenericTextAlignField = ({
);
};
export const EditorGenericVerticalAlignField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="verticalAlign"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Vertical Align</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder={t`Select vertical align`} />
</SelectTrigger>
<SelectContent>
<SelectItem value="top">
<Trans>Top</Trans>
</SelectItem>
<SelectItem value="middle">
<Trans>Middle</Trans>
</SelectItem>
<SelectItem value="bottom">
<Trans>Bottom</Trans>
</SelectItem>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLineHeightField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="lineHeight"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Line Height</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LINE_HEIGHT}
max={FIELD_MAX_LINE_HEIGHT}
className="bg-background"
placeholder={t`Line height`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericLetterSpacingField = ({
formControl,
className,
}: {
formControl: FormControlType;
className?: string;
}) => {
const { t } = useLingui();
return (
<FormField
control={formControl}
name="letterSpacing"
render={({ field }) => (
<FormItem className={className}>
<FormLabel>
<Trans>Letter Spacing</Trans>
</FormLabel>
<FormControl>
<Input
type="number"
min={FIELD_MIN_LETTER_SPACING}
max={FIELD_MAX_LETTER_SPACING}
className="bg-background"
placeholder={t`Letter spacing`}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};
export const EditorGenericRequiredField = ({
formControl,
className,

View File

@ -6,6 +6,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TInitialsFieldMeta as InitialsFieldMeta,
ZInitialsFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -39,7 +40,7 @@ export const EditorFieldInitialsForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -6,6 +6,7 @@ import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
type TNameFieldMeta as NameFieldMeta,
ZNameFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -39,7 +40,7 @@ export const EditorFieldNameForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
},
});

View File

@ -6,6 +6,11 @@ import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TNumberFieldMeta as NumberFieldMeta,
ZNumberFieldMeta,
} from '@documenso/lib/types/field-meta';
@ -31,9 +36,12 @@ import { Separator } from '@documenso/ui/primitives/separator';
import {
EditorGenericFontSizeField,
EditorGenericLabelField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
@ -43,6 +51,9 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
numberFormat: true,
fontSize: true,
textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true,
readOnly: true,
minValue: true,
@ -99,8 +110,11 @@ export const EditorFieldNumberForm = ({
placeholder: value.placeholder || '',
value: value.value || '',
numberFormat: value.numberFormat || null,
fontSize: value.fontSize || 14,
textAlign: value.textAlign || 'left',
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
required: value.required || false,
readOnly: value.readOnly || false,
minValue: value.minValue,
@ -118,6 +132,10 @@ export const EditorFieldNumberForm = ({
useEffect(() => {
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.value) {
void form.trigger('value');
}
if (validatedFormValues.success) {
onValueChange({
type: 'number',
@ -130,10 +148,12 @@ export const EditorFieldNumberForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<EditorGenericLabelField formControl={form.control} />
@ -204,6 +224,12 @@ export const EditorFieldNumberForm = ({
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>

View File

@ -5,11 +5,8 @@ import { Trans } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
type TSignatureFieldMeta,
ZSignatureFieldMeta,
} from '@documenso/lib/types/field-meta';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
import { Form } from '@documenso/ui/primitives/form/form';
import { EditorGenericFontSizeField } from './editor-field-generic-field-forms';
@ -35,7 +32,7 @@ export const EditorFieldSignatureForm = ({
resolver: zodResolver(ZSignatureFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
},
});

View File

@ -3,11 +3,16 @@ import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import { z } from 'zod';
import type { z } from 'zod';
import {
DEFAULT_FIELD_FONT_SIZE,
FIELD_DEFAULT_GENERIC_ALIGN,
FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
FIELD_DEFAULT_LETTER_SPACING,
FIELD_DEFAULT_LINE_HEIGHT,
type TTextFieldMeta as TextFieldMeta,
ZTextFieldMeta,
} from '@documenso/lib/types/field-meta';
import {
Form,
@ -22,32 +27,36 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
const ZTextFieldFormSchema = z
.object({
label: z.string().optional(),
placeholder: z.string().optional(),
text: z.string().optional(),
characterLimit: z.coerce.number().min(0).optional(),
fontSize: z.coerce.number().min(8).max(96).optional(),
textAlign: z.enum(['left', 'center', 'right']).optional(),
required: z.boolean().optional(),
readOnly: z.boolean().optional(),
})
.refine(
(data) => {
// A read-only field must have text
return !data.readOnly || (data.text && data.text.length > 0);
},
{
message: 'A read-only field must have text',
path: ['text'],
},
);
const ZTextFieldFormSchema = ZTextFieldMeta.pick({
label: true,
placeholder: true,
text: true,
characterLimit: true,
fontSize: true,
textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true,
readOnly: true,
}).refine(
(data) => {
// A read-only field must have text
return !data.readOnly || (data.text && data.text.length > 0);
},
{
message: 'A read-only field must have text',
path: ['text'],
},
);
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
@ -73,7 +82,10 @@ export const EditorFieldTextForm = ({
text: value.text || '',
characterLimit: value.characterLimit || 0,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign || 'left',
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
lineHeight: value.lineHeight ?? FIELD_DEFAULT_LINE_HEIGHT,
letterSpacing: value.letterSpacing ?? FIELD_DEFAULT_LETTER_SPACING,
verticalAlign: value.verticalAlign ?? FIELD_DEFAULT_GENERIC_VERTICAL_ALIGN,
required: value.required || false,
readOnly: value.readOnly || false,
},
@ -89,6 +101,10 @@ export const EditorFieldTextForm = ({
useEffect(() => {
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.text) {
void form.trigger('text');
}
if (validatedFormValues.success) {
onValueChange({
type: 'text',
@ -101,10 +117,12 @@ export const EditorFieldTextForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<div className="flex w-full flex-row gap-x-4">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
</div>
<FormField
@ -152,6 +170,18 @@ export const EditorFieldTextForm = ({
className="h-auto"
placeholder={t`Add text to the field`}
{...field}
onChange={(e) => {
const values = form.getValues();
const characterLimit = values.characterLimit || 0;
let textValue = e.target.value;
if (characterLimit > 0 && textValue.length > characterLimit) {
textValue = textValue.slice(0, characterLimit);
}
e.target.value = textValue;
field.onChange(e);
}}
rows={1}
/>
</FormControl>
@ -170,11 +200,22 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
className="bg-background"
placeholder={t`Field character limit`}
placeholder={t`Character limit`}
{...field}
value={field.value || ''}
onChange={(e) => {
const values = form.getValues();
const characterLimit = parseInt(e.target.value, 10) || 0;
field.onChange(characterLimit || '');
const textValue = values.text || '';
if (characterLimit > 0 && textValue.length > characterLimit) {
form.setValue('text', textValue.slice(0, characterLimit));
}
}}
/>
</FormControl>
<FormMessage />
@ -182,6 +223,12 @@ export const EditorFieldTextForm = ({
)}
/>
<div className="flex w-full flex-row gap-x-4">
<EditorGenericLineHeightField className="w-full" formControl={form.control} />
<EditorGenericLetterSpacingField className="w-full" formControl={form.control} />
</div>
<div className="mt-1">
<EditorGenericRequiredField formControl={form.control} />
</div>

View File

@ -92,6 +92,7 @@ export const SignInForm = ({
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
@ -317,6 +318,8 @@ export const SignInForm = ({
if (email) {
form.setValue('email', email);
}
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, [form]);
return (
@ -383,56 +386,64 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{hasSocialAuthEnabled && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or continue with</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
)}
{!isEmbeddedRedirect && (
<>
{hasSocialAuthEnabled && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">
<Trans>Or continue with</Trans>
</span>
<div className="bg-border h-px flex-1" />
</div>
)}
{isGoogleSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
)}
{isGoogleSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithGoogleClick}
>
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
)}
{isMicrosoftSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithMicrosoftClick}
>
<img className="mr-2 h-4 w-4" alt="Microsoft Logo" src={'/static/microsoft.svg'} />
Microsoft
</Button>
)}
{isMicrosoftSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithMicrosoftClick}
>
<img
className="mr-2 h-4 w-4"
alt="Microsoft Logo"
src={'/static/microsoft.svg'}
/>
Microsoft
</Button>
)}
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
{oidcProviderLabel || 'OIDC'}
</Button>
{isOIDCSSOEnabled && (
<Button
type="button"
size="lg"
variant="outline"
className="bg-background text-muted-foreground border"
disabled={isSubmitting}
onClick={onSignInWithOIDCClick}
>
<FaIdCardClip className="mr-2 h-5 w-5" />
{oidcProviderLabel || 'OIDC'}
</Button>
)}
</>
)}
<Button

View File

@ -68,6 +68,7 @@ export type SignUpFormProps = {
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
returnTo?: string;
};
export const SignUpForm = ({
@ -76,6 +77,7 @@ export const SignUpForm = ({
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
}: SignUpFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -110,7 +112,7 @@ export const SignUpForm = ({
signature,
});
await navigate(`/unverified-account`);
await navigate(returnTo ? returnTo : '/unverified-account');
toast({
title: _(msg`Registration Successful`),

View File

@ -9,6 +9,7 @@ import { useHotkeys } from 'react-hotkeys-hook';
import { useNavigate } from 'react-router';
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 { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import {
@ -63,10 +64,12 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
const [search, setSearch] = useState('');
const [pages, setPages] = useState<string[]>([]);
const debouncedSearch = useDebouncedValue(search, 200);
const { data: searchDocumentsData, isPending: isSearchingDocuments } =
trpcReact.document.search.useQuery(
{
query: search,
query: debouncedSearch,
},
{
placeholderData: (previousData) => previousData,
@ -232,6 +235,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<Trans>No results found.</Trans>
</CommandEmpty>
)}
{!currentPage && (
<>
{documentPageLinks.length > 0 && (
@ -239,14 +243,17 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<Commands push={push} pages={documentPageLinks} />
</CommandGroup>
)}
{templatePageLinks.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Templates`)}>
<Commands push={push} pages={templatePageLinks} />
</CommandGroup>
)}
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Settings`)}>
<Commands push={push} pages={SETTINGS_PAGES} />
</CommandGroup>
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Preferences`)}>
<CommandItem className="-mx-2 -my-1 rounded-lg" onSelect={() => addPage('language')}>
Change language
@ -255,6 +262,7 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
Change theme
</CommandItem>
</CommandGroup>
{searchResults.length > 0 && (
<CommandGroup className="mx-2 p-0 pb-2" heading={_(msg`Your documents`)}>
<Commands push={push} pages={searchResults} />

View File

@ -153,7 +153,9 @@ export const DirectTemplatePageView = ({
<CardContent className="p-2">
<PDFViewer
key={template.id}
documentData={template.templateDocumentData}
envelopeItem={template.envelopeItems[0]}
token={directTemplateRecipient.token}
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>

View File

@ -22,7 +22,7 @@ export const DocumentSigningAuthAccount = ({
actionVerb = 'sign',
onOpenChange,
}: DocumentSigningAuthAccountProps) => {
const { recipient } = useRequiredDocumentSigningAuthContext();
const { recipient, isDirectTemplate } = useRequiredDocumentSigningAuthContext();
const { t } = useLingui();
@ -34,8 +34,10 @@ export const DocumentSigningAuthAccount = ({
try {
setIsSigningOut(true);
const currentPath = `${window.location.pathname}${window.location.search}${window.location.hash}`;
await authClient.signOut({
redirectPath: `/signin#email=${email}`,
redirectPath: `/signin?returnTo=${encodeURIComponent(currentPath)}#embedded=true&email=${isDirectTemplate ? '' : email}`,
});
} catch {
setIsSigningOut(false);
@ -55,16 +57,28 @@ export const DocumentSigningAuthAccount = ({
<AlertDescription>
{actionTarget === 'DOCUMENT' && recipient.role === RecipientRole.VIEWER ? (
<span>
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
{isDirectTemplate ? (
<Trans>To mark this document as viewed, you need to be logged in.</Trans>
) : (
<Trans>
To mark this document as viewed, you need to be logged in as{' '}
<strong>{recipient.email}</strong>
</Trans>
)}
</span>
) : (
<span>
{/* Todo: Translate */}
To {actionVerb.toLowerCase()} this {actionTarget.toLowerCase()}, you need to be logged
in as <strong>{recipient.email}</strong>
{isDirectTemplate ? (
<Trans>
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>
)}
</AlertDescription>

View File

@ -47,7 +47,8 @@ export const DocumentSigningAuthDialog = ({
onOpenChange,
onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
@ -168,7 +169,11 @@ export const DocumentSigningAuthDialog = ({
match({ documentAuthType: selectedAuthType, user })
.with(
{ 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} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (

View File

@ -40,6 +40,7 @@ export type DocumentSigningAuthContextValue = {
derivedRecipientAccessAuth: TRecipientAccessAuthTypes[];
derivedRecipientActionAuth: TRecipientActionAuthTypes[];
isAuthRedirectRequired: boolean;
isDirectTemplate?: boolean;
isCurrentlyAuthenticating: boolean;
setIsCurrentlyAuthenticating: (_value: boolean) => void;
passkeyData: PasskeyData;
@ -68,6 +69,7 @@ export const useRequiredDocumentSigningAuthContext = () => {
export interface DocumentSigningAuthProviderProps {
documentAuthOptions: Envelope['authOptions'];
recipient: SigningAuthRecipient;
isDirectTemplate?: boolean;
user?: SessionUser | null;
children: React.ReactNode;
}
@ -75,6 +77,7 @@ export interface DocumentSigningAuthProviderProps {
export const DocumentSigningAuthProvider = ({
documentAuthOptions: initialDocumentAuthOptions,
recipient: initialRecipient,
isDirectTemplate = false,
user,
children,
}: DocumentSigningAuthProviderProps) => {
@ -204,6 +207,7 @@ export const DocumentSigningAuthProvider = ({
derivedRecipientAccessAuth,
derivedRecipientActionAuth,
isAuthRedirectRequired,
isDirectTemplate,
isCurrentlyAuthenticating,
setIsCurrentlyAuthenticating,
passkeyData,

View File

@ -34,6 +34,7 @@ import {
} from '@documenso/ui/primitives/form/form';
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 { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure';
@ -102,6 +103,8 @@ export const DocumentSigningCompleteDialog = ({
const { derivedRecipientAccessAuth } = useRequiredDocumentSigningAuthContext();
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const form = useForm<TNextSignerFormSchema>({
resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined,
defaultValues: {
@ -267,7 +270,12 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Your Name</Trans>
</FormLabel>
<FormControl>
<Input {...field} className="mt-2" placeholder={t`Enter your name`} />
<Input
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked}
/>
</FormControl>
<FormMessage />
@ -289,6 +297,7 @@ export const DocumentSigningCompleteDialog = ({
type="email"
className="mt-2"
placeholder={t`Enter your email`}
disabled={!!field.value && isEmailLocked}
/>
</FormControl>
<FormMessage />

View File

@ -8,6 +8,9 @@ import { match } from 'ts-pattern';
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 { EnvelopeSignerCompleteDialog } from '../envelope-signing/envelope-signing-complete-dialog';
import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
@ -15,6 +18,8 @@ import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
export const DocumentSigningMobileWidget = () => {
const [isExpanded, setIsExpanded] = useState(false);
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
useRequiredEnvelopeSigningContext();
@ -29,7 +34,7 @@ export const DocumentSigningMobileWidget = () => {
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-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">
{/* Main Header Bar */}
<div className="flex items-center justify-between gap-4 p-4">
@ -114,6 +119,13 @@ export const DocumentSigningMobileWidget = () => {
{isExpanded && (
<div className="border-border animate-in slide-in-from-bottom-2 border-t p-4 duration-200">
<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>

View File

@ -245,7 +245,12 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient>
<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>
</Card>
</div>

View File

@ -22,7 +22,9 @@ import { SignFieldNameDialog } from '~/components/dialogs/sign-field-name-dialog
import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-dialog';
import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-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 { EnvelopeItemSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeSignerForm from '../envelope-signing/envelope-signer-form';
@ -48,6 +50,13 @@ export const DocumentSigningPageViewV2 = () => {
selectedAssistantRecipientFields,
} = useRequiredEnvelopeSigningContext();
const {
isEmbed = false,
allowDocumentRejection = true,
hidePoweredBy = true,
onDocumentRejected,
} = useEmbedSigningContext() || {};
/**
* The total remaining fields remaining for the current recipient or selected assistant recipient.
*
@ -77,7 +86,7 @@ export const DocumentSigningPageViewV2 = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* 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">
<h3 className="text-foreground flex items-end justify-between text-sm font-semibold">
{match(recipient.role)
@ -107,7 +116,7 @@ export const DocumentSigningPageViewV2 = () => {
/>
</div>
<div className="mt-6 space-y-3">
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
@ -116,7 +125,7 @@ export const DocumentSigningPageViewV2 = () => {
{/* Quick Actions. */}
{!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">
<Trans>Actions</Trans>
</h4>
@ -145,10 +154,21 @@ export const DocumentSigningPageViewV2 = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && (
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}
onRejected={
onDocumentRejected &&
((reason) =>
onDocumentRejected({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
envelopeId: envelope.id,
recipientId: recipient.id,
reason,
}))
}
trigger={
<Button
variant="ghost"
@ -164,18 +184,22 @@ export const DocumentSigningPageViewV2 = () => {
</div>
)}
{/* Footer of left sidebar. */}
<div className="mt-auto px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
<div className="embed--DocumentWidgetFooter mt-auto">
{/* Footer of left sidebar. */}
{!isEmbed && (
<div className="px-4">
<Button asChild variant="ghost" className="w-full justify-start">
<Link to="/">
<ArrowLeftIcon className="mr-2 h-4 w-4" />
<Trans>Return</Trans>
</Link>
</Button>
</div>
)}
</div>
</div>
<div className="flex-1 overflow-y-auto">
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@ -202,11 +226,11 @@ export const DocumentSigningPageViewV2 = () => {
)}
{/* 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 ? (
<PDFViewerKonvaLazy
renderer="signing"
key={currentEnvelopeItem.id}
documentDataId={currentEnvelopeItem.documentDataId}
customPageRenderer={EnvelopeSignerPageRenderer}
/>
) : (
@ -218,9 +242,20 @@ export const DocumentSigningPageViewV2 = () => {
)}
{/* Mobile widget - Additional padding to allow users to scroll */}
<div className="block pb-16 md:hidden">
<div className="block pb-28 lg:hidden">
<DocumentSigningMobileWidget />
</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>

View File

@ -13,6 +13,7 @@ import { prop, sortBy } from 'remeda';
import { isBase64Image } from '@documenso/lib/constants/signatures';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import type { EnvelopeForSigningResponse } from '@documenso/lib/server-only/envelope/get-envelope-for-recipient-signing';
import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import {
isFieldUnsignedAndRequired,
isRequiredField,
@ -51,7 +52,11 @@ export type EnvelopeSigningContextValue = {
setSelectedAssistantRecipientId: (_value: number | null) => void;
selectedAssistantRecipient: EnvelopeForSigningResponse['envelope']['recipients'][number] | null;
signField: (_fieldId: number, _value: TSignEnvelopeFieldValue) => Promise<void>;
signField: (
_fieldId: number,
_value: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => Promise<Pick<Field, 'id' | 'inserted'>>;
};
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
@ -284,19 +289,26 @@ export const EnvelopeSigningProvider = ({
: null;
}, [envelope.documentMeta?.signingOrder, envelope.recipients, recipient.id]);
const signField = async (fieldId: number, fieldValue: TSignEnvelopeFieldValue) => {
const signField = async (
fieldId: number,
fieldValue: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
// Set the field locally for direct templates.
if (isDirectTemplate) {
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return;
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return signedField;
}
await signEnvelopeField({
const { signedField } = await signEnvelopeField({
token: envelopeData.recipient.token,
fieldId,
fieldValue,
authOptions: undefined,
authOptions,
});
return signedField;
};
const handleDirectTemplateFieldInsertion = (
@ -354,6 +366,8 @@ export const EnvelopeSigningProvider = ({
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
},
}));
return updatedField;
};
return (

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import {
@ -22,9 +23,10 @@ import {
} from '@documenso/ui/primitives/dialog';
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 EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
import { ShareDocumentDownloadButton } from '../share-document-download-button';
export type DocumentCertificateQRViewProps = {
documentId: number;
@ -34,6 +36,7 @@ export type DocumentCertificateQRViewProps = {
documentTeamUrl: string;
recipientCount?: number;
completedDate?: Date;
token: string;
};
export const DocumentCertificateQRView = ({
@ -44,6 +47,7 @@ export const DocumentCertificateQRView = ({
documentTeamUrl,
recipientCount = 0,
completedDate,
token,
}: DocumentCertificateQRViewProps) => {
const { data: documentViaUser } = trpc.document.get.useQuery({
documentId,
@ -96,11 +100,19 @@ export const DocumentCertificateQRView = ({
)}
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<EnvelopeRenderProvider
envelope={{
envelopeItems,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
token={token}
>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
formattedDate={formattedDate}
token={token}
/>
</EnvelopeRenderProvider>
) : (
@ -119,14 +131,27 @@ export const DocumentCertificateQRView = ({
</div>
</div>
<ShareDocumentDownloadButton
title={title}
documentData={envelopeItems[0].documentData}
<EnvelopeDownloadDialog
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
</div>
<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>
</>
)}
@ -138,14 +163,16 @@ type DocumentCertificateQrV2Props = {
title: string;
recipientCount: number;
formattedDate: string;
token: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
@ -163,18 +190,24 @@ const DocumentCertificateQrV2 = ({
</div>
</div>
{currentEnvelopeItem && (
<ShareDocumentDownloadButton
title={title}
documentData={currentEnvelopeItem.documentData}
/>
)}
<EnvelopeDownloadDialog
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
</div>
<div className="mt-12 w-full">
<EnvelopeRendererFileSelector className="mb-4 p-0" fields={[]} secondaryOverride={''} />
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy renderer="preview" customPageRenderer={EnvelopeGenericPageRenderer} />
</div>
</div>
);

View File

@ -16,9 +16,9 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT, IS_BILLING_ENABLED } from '@documenso/l
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -62,14 +62,18 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
try {
setIsLoading(true);
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
const payload = {
title: file.name,
documentDataId: response.id,
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
timezone: userTimezone,
folderId: folderId ?? undefined,
});
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits();

View File

@ -441,9 +441,10 @@ export const DocumentEditForm = ({
>
<CardContent className="p-2">
<PDFViewer
key={document.documentData.id}
documentData={document.documentData}
document={document}
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={undefined}
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>

View File

@ -1,18 +1,14 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@ -23,9 +19,6 @@ export type DocumentPageViewButtonProps = {
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
const isRecipient = !!recipient;
@ -37,25 +30,6 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
const documentsPath = formatDocumentsPath(envelope.team.url);
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({
isRecipient,
isPending,
@ -95,7 +69,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
</Link>
</Button>
))
.with({ isComplete: true, internalVersion: 2 }, () => (
.with({ isComplete: true }, () => (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
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);
};

View File

@ -1,6 +1,5 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
@ -16,13 +15,11 @@ import {
} from 'lucide-react';
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 type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
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 {
DropdownMenu,
@ -67,64 +64,6 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
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');
return (
@ -147,36 +86,20 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem>
)}
{envelope.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
token={recipient?.token}
envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
}
/>
) : (
<>
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
token={recipient?.token}
envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onDownloadOriginalClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans>
</div>
</DropdownMenuItem>
</>
)}
}
/>
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${envelope.id}/logs`}>
@ -250,7 +173,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
{isDuplicateDialogOpen && (
<DocumentDuplicateDialog
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
id={envelope.id}
token={recipient?.token}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@ -7,6 +7,7 @@ import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
export type DocumentPageViewInformationProps = {
userId: number;
@ -40,6 +41,10 @@ export const DocumentPageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
{
description: msg`Document ID (Legacy)`,
value: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, envelope, userId]);

View File

@ -13,9 +13,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
@ -73,14 +73,18 @@ export const DocumentUploadButton = ({ className }: DocumentUploadButtonProps) =
try {
setIsLoading(true);
const response = await putPdfFile(file);
const { legacyDocumentId: id } = await createDocument({
const payload = {
title: file.name,
documentDataId: response.id,
timezone: userTimezone,
folderId: folderId ?? undefined,
});
} satisfies TCreateDocumentPayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
void refreshLimits();

View File

@ -14,9 +14,9 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateEnvelopePayload } from '@documenso/trpc/server/envelope-router/create-envelope.types';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentDropzone } from '@documenso/ui/primitives/document-upload';
import {
@ -78,35 +78,24 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
try {
setIsLoading(true);
const result = await Promise.all(
files.map(async (file) => {
try {
const response = await putPdfFile(file);
return {
title: file.name,
documentDataId: response.id,
};
} catch (err) {
console.error(err);
throw new Error('Failed to upload document');
}
}),
);
const envelopeItemsToCreate = result.filter(
(item): item is { title: string; documentDataId: string } => item !== undefined,
);
const { id } = await createEnvelope({
const payload = {
folderId,
type,
title: files[0].name,
items: envelopeItemsToCreate,
meta: {
timezone: userTimezone,
},
}).catch((error) => {
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
for (const file of files) {
formData.append('files', file);
}
const { id } = await createEnvelope(formData).catch((error) => {
console.error(error);
throw error;

View File

@ -26,7 +26,7 @@ import { fieldButtonList } from './envelope-editor-fields-drag-drop';
export default function EnvelopeEditorFieldsPageRenderer() {
const { t, i18n } = useLingui();
const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const interactiveTransformer = useRef<Transformer | null>(null);
@ -103,7 +103,6 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldUpdates.height = fieldPageHeight;
}
// Todo: envelopes Use id
editorFields.updateFieldByFormId(fieldFormId, fieldUpdates);
// Select the field if it is not already selected.
@ -114,7 +113,7 @@ export default function EnvelopeEditorFieldsPageRenderer() {
pageLayer.current?.batchDraw();
};
const renderFieldOnLayer = (field: TLocalField) => {
const unsafeRenderFieldOnLayer = (field: TLocalField) => {
if (!pageLayer.current) {
return;
}
@ -160,6 +159,15 @@ export default function EnvelopeEditorFieldsPageRenderer() {
fieldGroup.on('dragend', handleResizeOrMove);
};
const renderFieldOnLayer = (field: TLocalField) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
@ -608,13 +616,14 @@ export default function EnvelopeEditorFieldsPageRenderer() {
transform: 'translateX(-50%)',
zIndex: 50,
}}
className="text-muted-foreground grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border bg-white p-1 shadow-sm"
// Don't use darkmode for this component, it should look the same for both light/dark modes.
className="grid w-max grid-cols-5 gap-x-1 gap-y-0.5 rounded-md border border-gray-300 bg-white p-1 text-gray-500 shadow-sm"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
className="col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100 hover:text-gray-600"
>
{t(field.name)}
</button>

View File

@ -27,7 +27,8 @@ import type {
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
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 { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Separator } from '@documenso/ui/primitives/separator';
@ -112,9 +113,34 @@ export const EnvelopeEditorFieldsPage = () => {
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex h-full justify-center p-4">
<div className="mt-4 flex flex-col items-center justify-center">
{envelope.recipients.length === 0 && (
<Alert
variant="neutral"
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
>
<div className="flex flex-col gap-1">
<AlertTitle>
<Trans>Missing Recipients</Trans>
</AlertTitle>
<AlertDescription>
<Trans>You need at least one recipient to add fields</Trans>
</AlertDescription>
</div>
<Button asChild variant="outline">
<Link to={`${relativePath.editorPath}`}>
<Trans>Add Recipients</Trans>
</Link>
</Button>
</Alert>
)}
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeEditorFieldsPageRenderer} />
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -130,7 +156,7 @@ export const EnvelopeEditorFieldsPage = () => {
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && (
{currentEnvelopeItem && envelope.recipients.length > 0 && (
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
{/* Recipient selector section. */}
<section className="px-4">
@ -138,29 +164,15 @@ export const EnvelopeEditorFieldsPage = () => {
<Trans>Selected Recipient</Trans>
</h3>
{envelope.recipients.length === 0 ? (
<Alert variant="warning">
<AlertDescription className="flex flex-col gap-2">
<Trans>You need at least one recipient to add fields</Trans>
<Link to={`${relativePath.editorPath}`} className="text-sm">
<p>
<Trans>Click here to add a recipient</Trans>
</p>
</Link>
</AlertDescription>
</Alert>
) : (
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
)}
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
{editorFields.selectedRecipient &&
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (

View File

@ -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 { ConstructionIcon, FileTextIcon } from 'lucide-react';
import { FieldType, SigningStatus } 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 { 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 PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
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'));
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'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.
*/
@ -31,40 +195,41 @@ export const EnvelopeEditorPreviewPage = () => {
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
}, []);
// Override the parent renderer provider so we can inject custom fields.
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
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 */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</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 ? (
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -78,27 +243,28 @@ export const EnvelopeEditorPreviewPage = () => {
)}
</div>
</div>
</div>
{/* Right Section - Form Fields Panel */}
{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">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
{/* Right Section - Form Fields Panel */}
{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">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Preivew Mode</Trans>
</h3> */}
<Alert variant="neutral">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
<Alert variant="neutral">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Preview what the signed document will look like with placeholder data
</Trans>
</AlertDescription>
</Alert>
{/* <Alert variant="neutral">
{/* <Alert variant="neutral">
<RadioGroup
className="gap-y-1"
value={selectedPreviewMode}
@ -137,36 +303,37 @@ export const EnvelopeEditorPreviewPage = () => {
<div>Preview what a recipient will see</div>
<div>Preview the signed document</div> */}
</section>
</section>
{false && (
<AnimateGenericFadeInOut key={selectedPreviewMode}>
{selectedPreviewMode === 'recipient' && (
<>
<Separator className="my-4" />
{false && (
<AnimateGenericFadeInOut key={selectedPreviewMode}>
{selectedPreviewMode === 'recipient' && (
<>
<Separator className="my-4" />
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Selected Recipient</Trans>
</h3>
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Selected Recipient</Trans>
</h3>
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
</section>
</>
)}
</AnimateGenericFadeInOut>
)}
</div>
)}
</div>
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
</section>
</>
)}
</AnimateGenericFadeInOut>
)}
</div>
)}
</div>
</EnvelopeRenderProvider>
);
};

View File

@ -212,7 +212,7 @@ export const EnvelopeEditorRecipientForm = () => {
);
const hasDocumentBeenSent = recipients.some(
(recipient) => recipient.sendStatus === SendStatus.SENT,
(recipient) => recipient.role !== RecipientRole.CC && recipient.sendStatus === SendStatus.SENT,
);
const canRecipientBeModified = (recipientId?: number) => {
@ -482,30 +482,46 @@ export const EnvelopeEditorRecipientForm = () => {
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 hasAllowDictateNextSignerChanged =
envelope.documentMeta.allowDictateNextSigner !== data.allowDictateNextSigner;
const hasSignersChanged =
data.signers.length !== recipients.length ||
data.signers.some((signer) => {
envelopeRecipients.length !== recipients.length ||
envelopeRecipients.some((signer) => {
const recipient = recipients.find((recipient) => recipient.id === signer.id);
if (!recipient) {
return true;
}
const signerActionAuth = signer.actionAuth;
const recipientActionAuth = recipient.authOptions?.actionAuth || [];
return (
signer.email !== recipient.email ||
signer.name !== recipient.name ||
signer.role !== recipient.role ||
signer.signingOrder !== recipient.signingOrder ||
!isDeepEqual(signer.actionAuth, recipient.authOptions?.actionAuth)
!isDeepEqual(signerActionAuth, recipientActionAuth)
);
});
if (hasSignersChanged) {
setRecipientsDebounced(validatedFormValues.data.signers);
setRecipientsDebounced(envelopeRecipients);
}
if (hasSigningOrderChanged || hasAllowDictateNextSignerChanged) {

View File

@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope } = useCurrentEnvelopeEditor();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@ -186,14 +186,12 @@ export const EnvelopeEditorSettingsDialog = ({
documentAuth: envelope.authOptions,
});
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
externalId: envelope.externalId || '', // Todo: String or undefined?
const createDefaultValues = () => {
return {
externalId: envelope.externalId || '',
visibility: envelope.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
subject: envelope.documentMeta.subject ?? '',
message: envelope.documentMeta.message ?? '',
@ -210,10 +208,13 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
},
},
});
};
};
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: createDefaultValues(),
});
const envelopeHasBeenSent =
envelope.type === EnvelopeType.DOCUMENT &&
@ -229,7 +230,6 @@ export const EnvelopeEditorSettingsDialog = ({
const emails = emailData?.data || [];
// Todo: Envelopes this doesn't make sense (look at previous)
const canUpdateVisibility = canAccessTeamDocument(team.currentTeamRole, envelope.visibility);
const onFormSubmit = async (data: TAddSettingsFormSchema) => {
@ -240,9 +240,7 @@ export const EnvelopeEditorSettingsDialog = ({
.safeParse(data.globalAccessAuth);
try {
await updateEnvelope({
envelopeId: envelope.id,
envelopeType: envelope.type,
await updateEnvelopeAsync({
data: {
externalId: data.externalId || null,
visibility: data.visibility,
@ -297,7 +295,7 @@ export const EnvelopeEditorSettingsDialog = ({
]);
useEffect(() => {
form.reset();
form.reset(createDefaultValues());
setActiveTab('general');
}, [open, form]);
@ -323,7 +321,7 @@ export const EnvelopeEditorSettingsDialog = ({
<DialogContent className="flex w-full !max-w-5xl flex-row gap-0 p-0">
{/* Sidebar. */}
<div className="flex w-80 flex-col border-r bg-gray-50">
<div className="bg-accent/20 flex w-80 flex-col border-r">
<DialogHeader className="p-6 pb-4">
<DialogTitle>Document Settings</DialogTitle>
</DialogHeader>

View File

@ -18,9 +18,9 @@ import {
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { nanoid } from '@documenso/lib/universal/id';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { canEnvelopeItemsBeModified } from '@documenso/lib/utils/envelope';
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 {
Card,
@ -49,7 +49,7 @@ export const EnvelopeEditorUploadPage = () => {
const organisation = useCurrentOrganisation();
const { t } = useLingui();
const { envelope, setLocalEnvelope, relativePath } = useCurrentEnvelopeEditor();
const { envelope, setLocalEnvelope, relativePath, editorFields } = useCurrentEnvelopeEditor();
const { maximumEnvelopeItemCount, remaining } = useLimits();
const { toast } = useToast();
@ -67,8 +67,8 @@ export const EnvelopeEditorUploadPage = () => {
const { mutateAsync: createEnvelopeItems, isPending: isCreatingEnvelopeItems } =
trpc.envelope.item.createMany.useMutation({
onSuccess: (data) => {
const createdEnvelopes = data.createdEnvelopeItems.filter(
onSuccess: ({ data }) => {
const createdEnvelopes = data.filter(
(item) => !envelope.envelopeItems.find((envelopeItem) => envelopeItem.id === item.id),
);
@ -79,10 +79,10 @@ export const EnvelopeEditorUploadPage = () => {
});
const { mutateAsync: updateEnvelopeItems } = trpc.envelope.item.updateMany.useMutation({
onSuccess: (data) => {
onSuccess: ({ data }) => {
setLocalEnvelope({
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) {
return {
@ -114,36 +114,19 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
const result = await Promise.all(
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({
const payload = {
envelopeId: envelope.id,
items: envelopeItemsToCreate,
}).catch((error) => {
} satisfies TCreateEnvelopeItemsPayload;
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);
// Set error state on files in batch upload.
@ -165,7 +148,7 @@ export const EnvelopeEditorUploadPage = () => {
);
return filteredFiles.concat(
createdEnvelopeItems.map((item) => ({
data.map((item) => ({
id: item.id,
envelopeItemId: item.id,
title: item.title,
@ -182,9 +165,17 @@ export const EnvelopeEditorUploadPage = () => {
const onFileDelete = (envelopeItemId: string) => {
setLocalFiles((prev) => prev.filter((uploadingFile) => uploadingFile.id !== envelopeItemId));
const fieldsWithoutDeletedItem = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItemId,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.filter((item) => item.id !== envelopeItemId),
fields: envelope.fields.filter((field) => field.envelopeItemId !== envelopeItemId),
});
// Reset editor fields.
editorFields.resetForm(fieldsWithoutDeletedItem);
};
/**
@ -203,7 +194,6 @@ export const EnvelopeEditorUploadPage = () => {
debouncedUpdateEnvelopeItems(items);
};
// Todo: Envelopes - Sync into envelopes data
const debouncedUpdateEnvelopeItems = useDebounceFunction((files: LocalFile[]) => {
void updateEnvelopeItems({
envelopeId: envelope.id,

View File

@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({
{...buttonProps}
>
<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'
}`}
>

View File

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@ -8,11 +9,24 @@ import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/e
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
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() {
const { i18n } = useLingui();
const { currentEnvelopeItem, fields, getRecipientColorKey } = useCurrentEnvelopeRender();
const {
envelopeStatus,
currentEnvelopeItem,
fields,
recipients,
getRecipientColorKey,
setRenderError,
overrideSettings,
} = useCurrentEnvelopeRender();
const {
stage,
@ -28,44 +42,81 @@ export default function EnvelopeGenericPageRenderer() {
const { _className, scale } = pageContext;
const localPageFields = useMemo(
() =>
fields.filter(
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
}
return fields
.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
),
[fields, pageContext.pageNumber],
);
)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!recipient) {
throw new Error(`Recipient not found for field ${field.id}`);
}
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
return {
...field,
inserted: isInserted,
customText: isInserted ? field.customText : '',
recipient,
};
})
.filter(
({ inserted, fieldMeta, recipient }) =>
(recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) ||
fieldMeta?.readOnly,
);
}, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]);
const unsafeRenderFieldOnLayer = (field: GenericLocalField) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
const fieldTranslations = getClientSideFieldTranslations(i18n);
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
...field,
customText: '',
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
inserted: false,
fieldMeta: field.fieldMeta,
signature: {
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
},
},
translations: getClientSideFieldTranslations(i18n),
translations: fieldTranslations,
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: 'sign',
mode: overrideSettings?.mode ?? 'edit',
});
};
const renderFieldOnLayer = (field: GenericLocalField) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
@ -113,6 +164,16 @@ export default function EnvelopeGenericPageRenderer() {
className="relative w-full"
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. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>

View File

@ -8,6 +8,8 @@ import { Label } from '@documenso/ui/primitives/label';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
export default function EnvelopeSignerForm() {
@ -25,6 +27,8 @@ export default function EnvelopeSignerForm() {
setSelectedAssistantRecipientId,
} = useRequiredEnvelopeSigningContext();
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const hasSignatureField = useMemo(() => {
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
}, [recipientFields]);
@ -37,7 +41,7 @@ export default function EnvelopeSignerForm() {
if (recipient.role === RecipientRole.ASSISTANT) {
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
className="gap-0 space-y-2 shadow-none sm:space-y-3"
value={selectedAssistantRecipient?.id?.toString()}
@ -101,7 +105,8 @@ export default function EnvelopeSignerForm() {
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
disabled={isNameLocked}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
/>
</div>

View File

@ -16,6 +16,7 @@ import {
import { Separator } from '@documenso/ui/primitives/separator';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { useEmbedSigningContext } from '~/components/embed/embed-signing-context';
import { BrandingLogo } from '~/components/general/branding-logo';
import { BrandingLogoIcon } from '../branding-logo-icon';
@ -28,7 +29,7 @@ export const EnvelopeSignerHeader = () => {
useRequiredEnvelopeSigningContext();
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 */}
<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">
@ -72,7 +73,7 @@ export const EnvelopeSignerHeader = () => {
</div>
{/* 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">
<Plural
one="1 Field Remaining"
@ -85,7 +86,7 @@ export const EnvelopeSignerHeader = () => {
</div>
{/* Mobile Actions button */}
<div className="flex-shrink-0 md:hidden">
<div className="flex-shrink-0 lg:hidden">
<MobileDropdownMenu />
</div>
</nav>
@ -95,6 +96,8 @@ export const EnvelopeSignerHeader = () => {
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
const { allowDocumentRejection } = useEmbedSigningContext() || {};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -119,7 +122,7 @@ const MobileDropdownMenu = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && (
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}

View File

@ -1,7 +1,14 @@
import { useEffect, useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
import {
type Field,
FieldType,
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
@ -10,15 +17,22 @@ import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-rende
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { useOptionalSession } from '@documenso/lib/client-only/providers/session';
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 { TEnvelope } from '@documenso/lib/types/envelope';
import { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
import { renderField } from '@documenso/lib/universal/field-renderer/render-field';
import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers';
import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import type { TSignEnvelopeFieldValue } from '@documenso/trpc/server/envelope-router/sign-envelope-field.types';
import { EnvelopeRecipientFieldTooltip } from '@documenso/ui/components/document/envelope-recipient-field-tooltip';
import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-field-tooltip';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
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 { handleDropdownFieldClick } from '~/utils/field-signing/dropdown-field';
import { handleEmailFieldClick } from '~/utils/field-signing/email-field';
@ -28,20 +42,28 @@ import { handleNumberFieldClick } from '~/utils/field-signing/number-field';
import { handleSignatureFieldClick } from '~/utils/field-signing/signature-field';
import { handleTextFieldClick } from '~/utils/field-signing/text-field';
import { useRequiredDocumentSigningAuthContext } from '../document-signing/document-signing-auth-provider';
import { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
type GenericLocalField = TEnvelope['fields'][number] & {
recipient: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>;
};
export default function EnvelopeSignerPageRenderer() {
const { i18n } = useLingui();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
const { sessionData } = useOptionalSession();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const {
envelopeData,
recipient,
recipientFields,
recipientFieldsRemaining,
showPendingFieldTooltip,
signField,
signField: signFieldInternal,
email,
setEmail,
fullName,
@ -53,6 +75,8 @@ export default function EnvelopeSignerPageRenderer() {
isDirectTemplate,
} = useRequiredEnvelopeSigningContext();
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const {
stage,
pageLayer,
@ -80,7 +104,37 @@ export default function EnvelopeSignerPageRenderer() {
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
/**
* Returns fields that have been fully signed by other recipients for this specific
* page.
*/
const localPageOtherRecipientFields = useMemo((): GenericLocalField[] => {
const signedRecipients = envelope.recipients.filter(
(recipient) => recipient.signingStatus === SigningStatus.SIGNED,
);
return signedRecipients.flatMap((recipient) => {
return recipient.fields
.filter(
(field) =>
field.page === pageContext.pageNumber &&
field.envelopeItemId === currentEnvelopeItem?.id &&
(field.inserted || field.fieldMeta?.readOnly),
)
.map((field) => ({
...field,
recipient: {
id: recipient.id,
name: recipient.name,
email: recipient.email,
signingStatus: recipient.signingStatus,
role: recipient.role,
},
}));
});
}, [envelope.recipients, pageContext.pageNumber]);
const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
@ -237,7 +291,7 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload); // Todo: Envelopes - Handle errors
await signField(field.id, payload);
}
if (payload?.value) {
@ -318,7 +372,6 @@ export default function EnvelopeSignerPageRenderer() {
* SIGNATURE FIELD.
*/
.with({ type: FieldType.SIGNATURE }, (field) => {
// Todo: Envelopes - Reauth
handleSignatureFieldClick({
field,
signature,
@ -329,11 +382,21 @@ export default function EnvelopeSignerPageRenderer() {
.then(async (payload) => {
if (payload) {
fieldGroup.add(loadingSpinnerGroup);
await signField(field.id, payload);
}
if (payload?.value) {
setSignature(payload.value);
if (payload.value) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => {
await signField(field.id, payload, authOptions);
loadingSpinnerGroup.destroy();
},
actionTarget: field.type,
});
setSignature(payload.value);
} else {
await signField(field.id, payload);
}
}
})
.finally(() => {
@ -347,15 +410,92 @@ export default function EnvelopeSignerPageRenderer() {
fieldGroup.on('pointerdown', handleFieldGroupClick);
};
const renderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => {
try {
unsafeRenderFieldOnLayer(unparsedField);
} catch (err) {
console.error(err);
setRenderError(true);
}
};
const renderFields = () => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
// Render current recipient fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
// Render other recipient signed and inserted fields.
for (const field of localPageOtherRecipientFields) {
try {
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
...field,
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
fieldMeta: field.fieldMeta,
},
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: 'readOnly',
editable: false,
mode: 'sign',
});
} catch (err) {
console.error('Unable to render one or more fields belonging to other recipients.');
console.error(err);
}
}
};
const signField = async (
fieldId: number,
payload: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => {
try {
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) {
console.error(err);
toast({
title: t`Error`,
description: t`An error occurred while signing the field.`,
variant: 'destructive',
});
throw err;
}
};
/**
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
}
renderFields();
currentPageLayer.batchDraw();
};
@ -367,10 +507,7 @@ export default function EnvelopeSignerPageRenderer() {
return;
}
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
renderFields();
pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
@ -386,9 +523,7 @@ export default function EnvelopeSignerPageRenderer() {
// Rerender the whole page.
pageLayer.current.destroyChildren();
localPageFields.forEach((field) => {
renderFieldOnLayer(field); // Todo: Envelopes - [CRITICAL] Handle errors which prevent rendering
});
renderFields();
pageLayer.current.batchDraw();
}, [selectedAssistantRecipient]);
@ -415,6 +550,15 @@ export default function EnvelopeSignerPageRenderer() {
</EnvelopeFieldToolTip>
)}
{localPageOtherRecipientFields.map((field) => (
<EnvelopeRecipientFieldTooltip
key={field.id}
field={field}
showFieldStatus={true}
showRecipientTooltip={true}
/>
))}
{/* The element Konva will inject it's canvas into. */}
<div className="konva-container absolute inset-0 z-10 w-full" ref={konvaContainer}></div>

View File

@ -2,16 +2,19 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
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 { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
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 { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { trpc } from '@documenso/trpc/react';
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 { useRequiredEnvelopeSigningContext } from '../document-signing/envelope-signing-provider';
@ -19,8 +22,9 @@ export const EnvelopeSignerCompleteDialog = () => {
const navigate = useNavigate();
const analytics = useAnalytics();
const { toast } = useToast();
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [searchParams] = useSearchParams();
@ -37,6 +41,8 @@ export const EnvelopeSignerCompleteDialog = () => {
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
const { mutateAsync: completeDocument, isPending } =
trpc.recipient.completeDocumentWithToken.useMutation();
@ -68,25 +74,54 @@ export const EnvelopeSignerCompleteDialog = () => {
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => {
const payload = {
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
authOptions: accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
try {
const payload = {
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
authOptions: accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
await completeDocument(payload);
await completeDocument(payload);
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
analytics.capture('App: Recipient has completed signing', {
signerId: recipient.id,
documentId: envelope.id,
timestamp: new Date().toISOString(),
});
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
if (onDocumentCompleted) {
onDocumentCompleted({
token: recipient.token,
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);
}
if (!recipient.directToken) {
throw new Error('Recipient direct token is required');
}
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,
directRecipientName: recipientDetails?.name || fullName,
directRecipientEmail: recipientDetails?.email || email,
@ -132,18 +171,31 @@ export const EnvelopeSignerCompleteDialog = () => {
const redirectUrl = envelope.documentMeta.redirectUrl;
if (onDocumentCompleted) {
await navigate({
pathname: `/embed/sign/${token}`,
search: window.location.search,
hash: window.location.hash,
});
return;
}
if (redirectUrl) {
window.location.href = redirectUrl;
} else {
await navigate(`/sign/${token}/complete`);
}
} catch (err) {
console.log('err', err);
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;
}
};

View File

@ -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>
);
};

View File

@ -10,9 +10,9 @@ import { match } from 'ts-pattern';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
@ -40,13 +40,17 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
try {
setIsLoading(true);
const documentData = await putPdfFile(file);
const { legacyTemplateId: id } = await createTemplate({
const payload = {
title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined,
});
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({
title: _(msg`Template uploaded`),

View File

@ -313,8 +313,10 @@ export const TemplateEditForm = ({
>
<CardContent className="p-2">
<PDFViewer
key={templateDocumentData.id}
documentData={templateDocumentData}
key={template.envelopeItems[0].id}
envelopeItem={template.envelopeItems[0]}
token={undefined}
version="signed"
onDocumentLoad={() => setIsDocumentPdfLoaded(true)}
/>
</CardContent>

View File

@ -7,11 +7,13 @@ import type { User } from '@prisma/client';
import { DateTime } from 'luxon';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
export type TemplatePageViewInformationProps = {
userId: number;
template: {
userId: number;
secondaryId: string;
createdAt: Date;
updatedAt: Date;
user: Pick<User, 'id' | 'name' | 'email'>;
@ -43,6 +45,10 @@ export const TemplatePageViewInformation = ({
.setLocale(i18n.locales?.[0] || i18n.locale)
.toRelative(),
},
{
description: msg`Template ID (Legacy)`,
value: mapSecondaryIdToTemplateId(template.secondaryId),
},
];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMounted, template, userId]);

View File

@ -1,19 +1,14 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
@ -25,8 +20,6 @@ export type DocumentsTableActionButtonProps = {
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
@ -44,39 +37,6 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const documentsPath = formatDocumentsPath(team.url);
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
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
@ -134,7 +94,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans>
</Button>
))
.with({ isComplete: true, internalVersion: 2 }, () => (
.with({ isComplete: true }, () => (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
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>);
};

View File

@ -10,7 +10,6 @@ import {
Download,
Edit,
EyeIcon,
FileDown,
FolderInput,
Loader,
MoreHorizontal,
@ -20,12 +19,10 @@ import {
} from 'lucide-react';
import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
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 {
DropdownMenu,
@ -34,7 +31,6 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
@ -56,7 +52,6 @@ export const DocumentsTableActionDropdown = ({
const { user } = useSession();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -76,58 +71,6 @@ export const DocumentsTableActionDropdown = ({
const documentsPath = formatDocumentsPath(team.url);
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');
return (
@ -178,33 +121,19 @@ export const DocumentsTableActionDropdown = ({
</Link>
</DropdownMenuItem>
{row.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
}
/>
) : (
<>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={onDownloadOriginalClick}>
<FileDown className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans>
</DropdownMenuItem>
</>
)}
}
/>
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
@ -273,7 +202,8 @@ export const DocumentsTableActionDropdown = ({
/>
<DocumentDuplicateDialog
id={row.id}
id={row.envelopeId}
token={recipient?.token}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@ -10,11 +10,9 @@ import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
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 { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
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 { useOptionalCurrentTeam } from '~/providers/team';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
@ -199,28 +198,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
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
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
@ -230,6 +207,7 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
isSigned,
internalVersion: row.internalVersion,
})
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
@ -263,10 +241,17 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
</Button>
))
.with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}>
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
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>);
};

View File

@ -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">
<EnvelopeRenderProvider
envelope={envelope}
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
overrideSettings={{
showRecipientSigningStatus: true,
showRecipientTooltip: true,
}}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
@ -156,7 +161,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
</CardContent>
</Card>
</EnvelopeRenderProvider>
@ -178,9 +186,10 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
)}
<PDFViewer
document={envelope}
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
version="signed"
/>
</CardContent>
</Card>

View File

@ -101,8 +101,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) {
<EnvelopeEditorProvider initialEnvelope={envelope}>
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
recipients={envelope.recipients}
>
<EnvelopeEditor />
</EnvelopeRenderProvider>

View File

@ -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">
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
recipients={envelope.recipients}
overrideSettings={{
showRecipientTooltip: true,
}}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
@ -179,7 +183,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
<PDFViewerKonvaLazy
renderer="preview"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
</CardContent>
</Card>
</EnvelopeRenderProvider>
@ -200,9 +207,10 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
/>
<PDFViewer
document={envelope}
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
version="signed"
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</CardContent>
</Card>

View File

@ -184,6 +184,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
isDirectTemplate={true}
user={user}
>
<>
@ -245,7 +246,7 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider envelope={envelope}>
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>

View File

@ -492,7 +492,7 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
recipient={recipient}
user={user}
>
<EnvelopeRenderProvider envelope={envelope}>
<EnvelopeRenderProvider envelope={envelope} token={recipient.token}>
<DocumentSigningPageViewV2 />
</EnvelopeRenderProvider>
</DocumentSigningAuthProvider>

View File

@ -1,10 +1,9 @@
import { useEffect } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
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 { 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 { env } from '@documenso/lib/utils/env';
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 { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { ClaimAccount } from '~/components/general/claim-account';
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">
<DocumentShareButton documentId={document.id} token={recipient.token} />
{isDocumentCompleted(document.status) ? (
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={document.documentData}
disabled={!isDocumentCompleted(document.status)}
/>
) : (
<DocumentDialog
documentData={document.documentData}
{isDocumentCompleted(document.status) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
trigger={
<Button
className="text-[11px]"
title={_(msg`Signatures will appear once the document has been completed`)}
variant="outline"
>
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
<Trans>View Original Document</Trans>
<Button type="button" variant="outline" className="flex-1">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>

View File

@ -60,6 +60,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
return {
document,
token: slug,
};
}
@ -74,7 +75,7 @@ export const loader = async ({ request, params: { slug } }: Route.LoaderArgs) =>
};
export default function SharePage() {
const { document } = useLoaderData<typeof loader>();
const { document, token } = useLoaderData<typeof loader>();
if (document) {
return (
@ -86,6 +87,7 @@ export default function SharePage() {
envelopeItems={document.envelopeItems}
recipientCount={document.recipientCount}
completedDate={document.completedAt ?? undefined}
token={token}
/>
);
}

View File

@ -1,3 +1,5 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
@ -9,6 +11,7 @@ import {
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
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 { appMetaTags } from '~/utils/meta';
@ -28,8 +31,12 @@ export async function loader({ request }: Route.LoaderArgs) {
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
if (isAuthenticated) {
throw redirect('/');
throw redirect(returnTo || '/');
}
return {
@ -37,12 +44,28 @@ export async function loader({ request }: Route.LoaderArgs) {
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData;
const {
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 (
<div className="w-screen max-w-lg px-4">
@ -61,13 +84,17 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
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">
<Trans>
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
</Link>
</Trans>

View File

@ -6,6 +6,7 @@ import {
IS_OIDC_SSO_ENABLED,
} from '@documenso/lib/constants/auth';
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 { appMetaTags } from '~/utils/meta';
@ -16,7 +17,7 @@ export function meta() {
return appMetaTags('Sign Up');
}
export function loader() {
export function loader({ request }: Route.LoaderArgs) {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
// SSR env variables.
@ -28,15 +29,20 @@ export function loader() {
throw redirect('/signin');
}
let returnTo = new URL(request.url).searchParams.get('returnTo') ?? undefined;
returnTo = isValidReturnTo(returnTo) ? normalizeReturnTo(returnTo) : undefined;
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
};
}
export default function SignUp({ loaderData }: Route.ComponentProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, returnTo } = loaderData;
return (
<SignUpForm
@ -44,6 +50,7 @@ export default function SignUp({ loaderData }: Route.ComponentProps) {
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
returnTo={returnTo}
/>
);
}

View File

@ -2,11 +2,14 @@ import { Outlet, isRouteErrorResponse, useRouteError } from 'react-router';
import {
IS_GOOGLE_SSO_ENABLED,
IS_MICROSOFT_SSO_ENABLED,
IS_OIDC_SSO_ENABLED,
OIDC_PROVIDER_LABEL,
} from '@documenso/lib/constants/auth';
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 { EmbedPaywall } from '~/components/embed/embed-paywall';
@ -29,11 +32,13 @@ export function headers({ loaderHeaders }: Route.HeadersArgs) {
export function loader() {
// SSR env variables.
const isGoogleSSOEnabled = IS_GOOGLE_SSO_ENABLED;
const isMicrosoftSSOEnabled = IS_MICROSOFT_SSO_ENABLED;
const isOIDCSSOEnabled = IS_OIDC_SSO_ENABLED;
const oidcProviderLabel = OIDC_PROVIDER_LABEL;
return {
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
};
@ -44,15 +49,19 @@ export default function Layout() {
}
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } = loaderData || {};
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData || {};
const error = useRouteError();
console.log({ routeError: error });
if (isRouteErrorResponse(error)) {
if (error.status === 401 && error.data.type === 'embed-authentication-required') {
return (
<EmbedAuthenticationRequired
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
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') {
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>;

View 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>
);
};

View File

@ -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>
);
}

View 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>
);
};

View File

@ -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>
);
}

View File

@ -67,6 +67,7 @@ export async function loader({ request }: Route.LoaderArgs) {
export default function MultisignPage() {
const { envelopes, user, hidePoweredBy, allowWhitelabelling } =
useSuperLoaderData<typeof loader>();
const revalidator = useRevalidator();
const [selectedDocument, setSelectedDocument] = useState<

View File

@ -1,5 +1,6 @@
import { FieldType } from '@prisma/client';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { TFieldCheckbox } from '@documenso/lib/types/field';
import { parseCheckboxCustomText } from '@documenso/lib/utils/fields';
@ -44,6 +45,13 @@ export const handleCheckboxFieldClick = async (
let checkedValues: number[] | null = newValues.filter((v) => v.isChecked).map((v) => v.index);
if (checkedValues.length === 0) {
return {
type: FieldType.CHECKBOX,
value: [],
};
}
if (validationRule && validationLength) {
const checkboxValidationRule = checkboxValidationSigns.find(
(sign) => sign.label === validationRule,
@ -55,12 +63,33 @@ export const handleCheckboxFieldClick = async (
});
}
checkedValues = await SignFieldCheckboxDialog.call({
fieldMeta: field.fieldMeta,
validationRule: checkboxValidationRule.value,
// Custom logic to make it flow better.
// If "at most" OR "exactly" 1 value then just return the new selected value if exists.
if (
(checkboxValidationRule.value === '=' || checkboxValidationRule.value === '<=') &&
validationLength === 1
) {
return {
type: FieldType.CHECKBOX,
value: [clickedCheckboxIndex],
};
}
const isValid = validateCheckboxLength(
checkedValues.length,
checkboxValidationRule.value,
validationLength,
preselectedIndices: currentCheckedIndices,
});
);
// Only render validation dialog if validation is invalid.
if (!isValid) {
checkedValues = await SignFieldCheckboxDialog.call({
fieldMeta: field.fieldMeta,
validationRule: checkboxValidationRule.value,
validationLength,
preselectedIndices: checkedValues,
});
}
}
if (!checkedValues) {

View File

@ -8,7 +8,7 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di
type HandleNumberFieldClickOptions = {
field: TFieldNumber;
number: number | null;
number: string | null;
};
export const handleNumberFieldClick = async (

View File

@ -25,6 +25,7 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@epic-web/remember": "^1.1.0",
"@faker-js/faker": "^10.1.0",
"@hono/node-server": "^1.13.7",
"@hono/trpc-server": "^0.3.4",
"@hookform/resolvers": "^3.1.0",
@ -40,6 +41,7 @@
"@simplewebauthn/server": "^9.0.3",
"autoprefixer": "^10.4.13",
"colord": "^2.9.3",
"content-disposition": "^0.5.4",
"framer-motion": "^10.12.8",
"hono": "4.7.0",
"hono-rate-limiter": "^0.4.2",
@ -86,6 +88,7 @@
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-typescript": "^12.1.2",
"@simplewebauthn/types": "^9.0.1",
"@types/content-disposition": "^0.5.9",
"@types/formidable": "^2.0.6",
"@types/luxon": "^3.3.1",
"@types/node": "^20",
@ -103,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.13.1"
}
"version": "2.0.6"
}

View 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);
}
},
);

View 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>;

View File

@ -1,100 +0,0 @@
import { PDFDocument } from '@cantoo/pdf-lib';
import { sValidator } from '@hono/standard-validator';
import { Hono } from 'hono';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { createDocumentData } from '@documenso/lib/server-only/document-data/create-document-data';
import { putFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import {
getPresignGetUrl,
getPresignPostUrl,
} from '@documenso/lib/universal/upload/server-actions';
import type { HonoEnv } from '../router';
import {
type TGetPresignedGetUrlResponse,
type TGetPresignedPostUrlResponse,
ZGetPresignedGetUrlRequestSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
export const filesRoute = new Hono<HonoEnv>()
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
const { file } = c.req.valid('form');
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Todo: (RR7) This is new.
// Add file size validation.
// Convert MB to bytes (1 MB = 1024 * 1024 bytes)
const MAX_FILE_SIZE = APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large' }, 400);
}
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer).catch((e) => {
console.error(`PDF upload parse error: ${e.message}`);
throw new AppError('INVALID_DOCUMENT_FILE');
});
if (pdf.isEncrypted) {
throw new AppError('INVALID_DOCUMENT_FILE');
}
// Todo: (RR7) Test this.
if (!file.name.endsWith('.pdf')) {
Object.defineProperty(file, 'name', {
writable: true,
value: `${file.name}.pdf`,
});
}
const { type, data } = await putFileServerSide(file);
const result = await createDocumentData({ type, data });
return c.json(result);
} catch (error) {
console.error('Upload failed:', error);
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-get-url', sValidator('json', ZGetPresignedGetUrlRequestSchema), async (c) => {
const { key } = await c.req.json();
try {
const { url } = await getPresignGetUrl(key || '');
return c.json({ url } satisfies TGetPresignedGetUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const { fileName, contentType } = c.req.valid('json');
try {
const { key, url } = await getPresignPostUrl(fileName, contentType);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
});

View File

@ -1,38 +0,0 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
export const ZUploadPdfRequestSchema = z.object({
file: z.instanceof(File),
});
export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
type: true,
id: true,
});
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export const ZGetPresignedGetUrlRequestSchema = z.object({
key: z.string().min(1),
});
export const ZGetPresignedGetUrlResponseSchema = z.object({
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export type TGetPresignedGetUrlRequest = z.infer<typeof ZGetPresignedGetUrlRequestSchema>;
export type TGetPresignedGetUrlResponse = z.infer<typeof ZGetPresignedGetUrlResponseSchema>;

View File

@ -0,0 +1,81 @@
import { type DocumentDataType, DocumentStatus } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import type { HonoEnv } from '../../router';
type HandleEnvelopeItemFileRequestOptions = {
title: string;
status: DocumentStatus;
documentData: {
type: DocumentDataType;
data: string;
initialData: string;
};
version: 'signed' | 'original';
isDownload: boolean;
context: Context<HonoEnv>;
};
/**
* Helper function to handle envelope item file requests (both view and download)
*/
export const handleEnvelopeItemFileRequest = async ({
title,
status,
documentData,
version,
isDownload,
context: c,
}: HandleEnvelopeItemFileRequestOptions) => {
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
if (c.req.header('If-None-Match') === etag && !isDownload) {
return c.body(null, 304);
}
const file = await getFileServerSide({
type: documentData.type,
data: documentDataToUse,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'File not found' }, 404);
}
c.header('Content-Type', 'application/pdf');
c.header('ETag', etag);
if (!isDownload) {
if (status === DocumentStatus.COMPLETED) {
c.header('Cache-Control', 'public, max-age=31536000, immutable');
} else {
c.header('Cache-Control', 'public, max-age=0, must-revalidate');
}
}
if (isDownload) {
// Generate filename following the pattern from envelope-download-dialog.tsx
const baseTitle = title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
c.header('Content-Disposition', contentDisposition(filename));
// For downloads, prevent caching to ensure fresh data
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
}
return c.body(file);
};

View File

@ -0,0 +1,307 @@
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
import { handleEnvelopeItemFileRequest } from './files.helpers';
import {
type TGetPresignedPostUrlResponse,
ZGetEnvelopeItemFileDownloadRequestParamsSchema,
ZGetEnvelopeItemFileRequestParamsSchema,
ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema,
ZGetEnvelopeItemFileTokenRequestParamsSchema,
ZGetPresignedPostUrlRequestSchema,
ZUploadPdfRequestSchema,
} from './files.types';
export const filesRoute = new Hono<HonoEnv>()
/**
* Uploads a document file to the appropriate storage location and creates
* a document data record.
*/
.post('/upload-pdf', sValidator('form', ZUploadPdfRequestSchema), async (c) => {
try {
const { file } = c.req.valid('form');
if (!file) {
return c.json({ error: 'No file provided' }, 400);
}
// Todo: (RR7) This is new.
// Add file size validation.
// Convert MB to bytes (1 MB = 1024 * 1024 bytes)
const MAX_FILE_SIZE = APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024;
if (file.size > MAX_FILE_SIZE) {
return c.json({ error: 'File too large' }, 400);
}
const result = await putNormalizedPdfFileServerSide(file);
return c.json(result);
} catch (error) {
console.error('Upload failed:', error);
return c.json({ error: 'Upload failed' }, 500);
}
})
.post('/presigned-post-url', sValidator('json', ZGetPresignedPostUrlRequestSchema), async (c) => {
const { fileName, contentType } = c.req.valid('json');
try {
const { key, url } = await getPresignPostUrl(fileName, contentType);
return c.json({ key, url } satisfies TGetPresignedPostUrlResponse);
} catch (err) {
console.error(err);
throw new AppError(AppErrorCode.UNKNOWN_ERROR);
}
})
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileRequestParamsSchema),
async (c) => {
const { envelopeId, envelopeItemId } = c.req.valid('param');
const session = await getOptionalSession(c);
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
},
include: {
documentData: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
userId: session.user.id,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
});
if (!team) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
);
}
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: 'signed',
isDownload: false,
context: c,
});
},
)
.get(
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileDownloadRequestParamsSchema),
async (c) => {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
const session = await getOptionalSession(c);
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
},
include: {
documentData: true,
},
},
},
});
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const team = await getTeamById({
userId: session.user.id,
teamId: envelope.teamId,
}).catch((error) => {
console.error(error);
return null;
});
if (!team) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
);
}
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,
isDownload: true,
context: c,
});
},
)
.get(
'/token/:token/envelopeItem/:envelopeItemId',
sValidator('param', ZGetEnvelopeItemFileTokenRequestParamsSchema),
async (c) => {
const { token, envelopeItemId } = c.req.valid('param');
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
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: 'signed',
isDownload: false,
context: c,
});
},
)
.get(
'/token/:token/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema),
async (c) => {
const { token, envelopeItemId, version } = c.req.valid('param');
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
};
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
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,
isDownload: true,
context: c,
});
},
);

View File

@ -0,0 +1,66 @@
import { z } from 'zod';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
export const ZUploadPdfRequestSchema = z.object({
file: z.instanceof(File),
});
export const ZUploadPdfResponseSchema = DocumentDataSchema.pick({
type: true,
id: true,
});
export type TUploadPdfRequest = z.infer<typeof ZUploadPdfRequestSchema>;
export type TUploadPdfResponse = z.infer<typeof ZUploadPdfResponseSchema>;
export const ZGetPresignedPostUrlRequestSchema = z.object({
fileName: z.string().min(1),
contentType: z.string().min(1),
});
export const ZGetPresignedPostUrlResponseSchema = z.object({
key: z.string().min(1),
url: z.string().min(1),
});
export type TGetPresignedPostUrlRequest = z.infer<typeof ZGetPresignedPostUrlRequestSchema>;
export type TGetPresignedPostUrlResponse = z.infer<typeof ZGetPresignedPostUrlResponseSchema>;
export const ZGetEnvelopeItemFileRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
});
export type TGetEnvelopeItemFileRequestParams = z.infer<
typeof ZGetEnvelopeItemFileRequestParamsSchema
>;
export const ZGetEnvelopeItemFileTokenRequestParamsSchema = z.object({
token: z.string().min(1),
envelopeItemId: z.string().min(1),
});
export type TGetEnvelopeItemFileTokenRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenRequestParamsSchema
>;
export const ZGetEnvelopeItemFileDownloadRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
});
export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileDownloadRequestParamsSchema
>;
export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
token: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
});
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
typeof ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema
>;

View File

@ -8,13 +8,14 @@ import type { Logger } from 'pino';
import { tsRestHonoApp } from '@documenso/api/hono';
import { auth } from '@documenso/auth/server';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { jobsClient } from '@documenso/lib/jobs/client';
import { getIpAddress } from '@documenso/lib/universal/get-ip-address';
import { logger } from '@documenso/lib/utils/logger';
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 { appMiddleware } from './middleware';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
@ -89,9 +90,26 @@ app.route('/api/v1', tsRestHonoApp);
app.use('/api/jobs/*', jobsClient.getApiHandler());
app.use('/api/trpc/*', reactRouterTrpcServer);
// Unstable API server routes. Order matters for these two.
app.get(`${API_V2_URL}/openapi.json`, (c) => c.json(openApiDocument));
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) =>
openApiTrpcServerHandler(c, {
isBeta: false,
}),
);
// Unstable API server routes. Order matters for these two.
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}/*`, async (c) => openApiTrpcServerHandler(c));
// 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) =>
openApiTrpcServerHandler(c, {
isBeta: true,
}),
);
export default app;

View File

@ -1,15 +1,22 @@
import type { Context } from 'hono';
import { createOpenApiFetchHandler } from 'trpc-to-openapi';
import { API_V2_BETA_URL } from '@documenso/lib/constants/app';
import { API_V2_BETA_URL, API_V2_URL } from '@documenso/lib/constants/app';
import { AppError, genericErrorCodeToTrpcErrorCodeMap } from '@documenso/lib/errors/app-error';
import { createTrpcContext } from '@documenso/trpc/server/context';
import { appRouter } from '@documenso/trpc/server/router';
import { createOpenApiFetchHandler } from '@documenso/trpc/utils/openapi-fetch-handler';
import { handleTrpcRouterError } from '@documenso/trpc/utils/trpc-error-handler';
export const openApiTrpcServerHandler = async (c: Context) => {
type OpenApiTrpcServerHandlerOptions = {
isBeta: boolean;
};
export const openApiTrpcServerHandler = async (
c: Context,
{ isBeta }: OpenApiTrpcServerHandlerOptions,
) => {
return createOpenApiFetchHandler<typeof appRouter>({
endpoint: API_V2_BETA_URL,
endpoint: isBeta ? API_V2_BETA_URL : API_V2_URL,
router: appRouter,
createContext: async () => createTrpcContext({ c, requestSource: 'apiV2' }),
req: c.req.raw,

Binary file not shown.

BIN
assets/field-meta.pdf Normal file

Binary file not shown.

797
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "1.13.1",
"version": "2.0.6",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@ -44,7 +44,7 @@
"@commitlint/cli": "^17.7.1",
"@commitlint/config-conventional": "^17.7.0",
"@lingui/cli": "^5.2.0",
"@prisma/client": "^6.8.2",
"@prisma/client": "^6.18.0",
"dotenv": "^16.5.0",
"dotenv-cli": "^8.0.0",
"eslint": "^8.40.0",
@ -54,11 +54,22 @@
"nodemailer": "^6.10.1",
"playwright": "1.52.0",
"prettier": "^3.3.3",
"prisma": "^6.8.2",
"prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1",
"turbo": "^1.9.3",
"@trpc/client": "11.7.0",
"@trpc/react-query": "11.7.0",
"@trpc/server": "11.7.0",
"superjson": "^2.2.5",
"trpc-to-openapi": "2.4.0",
"zod-openapi": "^4.2.4",
"@ts-rest/core": "^3.52.1",
"@ts-rest/open-api": "^3.52.1",
"@ts-rest/serverless": "^3.52.1",
"zod-prisma-types": "3.3.5",
"vite": "^6.3.5"
},
"name": "@documenso/root",
@ -76,10 +87,10 @@
"mupdf": "^1.0.0",
"react": "^18",
"typescript": "5.6.2",
"zod": "3.24.1"
"zod": "^3.25.76"
},
"overrides": {
"zod": "3.24.1"
"zod": "^3.25.76"
},
"trigger.dev": {
"endpointId": "documenso-app"

View File

@ -17,14 +17,14 @@
"dependencies": {
"@documenso/lib": "*",
"@documenso/prisma": "*",
"@ts-rest/core": "^3.30.5",
"@ts-rest/open-api": "^3.33.0",
"@ts-rest/serverless": "^3.30.5",
"@ts-rest/core": "^3.52.0",
"@ts-rest/open-api": "^3.52.0",
"@ts-rest/serverless": "^3.52.0",
"@types/swagger-ui-react": "^5.18.0",
"luxon": "^3.4.0",
"superjson": "^1.13.1",
"superjson": "^2.2.5",
"swagger-ui-react": "^5.21.0",
"ts-pattern": "^5.0.5",
"zod": "3.24.1"
"zod": "^3.25.76"
}
}

View File

@ -20,12 +20,12 @@ import {
getEnvelopeWhereInput,
} from '@documenso/lib/server-only/envelope/get-envelope-by-id';
import { deleteDocumentField } from '@documenso/lib/server-only/field/delete-document-field';
import { updateDocumentFields } from '@documenso/lib/server-only/field/update-document-fields';
import { updateEnvelopeFields } from '@documenso/lib/server-only/field/update-envelope-fields';
import { insertFormValuesInPdf } from '@documenso/lib/server-only/pdf/insert-form-values-in-pdf';
import { deleteDocumentRecipient } from '@documenso/lib/server-only/recipient/delete-document-recipient';
import { deleteEnvelopeRecipient } from '@documenso/lib/server-only/recipient/delete-envelope-recipient';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { setDocumentRecipients } from '@documenso/lib/server-only/recipient/set-document-recipients';
import { updateDocumentRecipients } from '@documenso/lib/server-only/recipient/update-document-recipients';
import { updateEnvelopeRecipients } from '@documenso/lib/server-only/recipient/update-envelope-recipients';
import { createDocumentFromTemplate } from '@documenso/lib/server-only/template/create-document-from-template';
import { deleteTemplate } from '@documenso/lib/server-only/template/delete-template';
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
@ -1285,7 +1285,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const updatedRecipient = await updateDocumentRecipients({
const updatedRecipient = await updateEnvelopeRecipients({
userId: user.id,
teamId: team.id,
id: {
@ -1336,7 +1336,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
},
});
const deletedRecipient = await deleteDocumentRecipient({
const deletedRecipient = await deleteEnvelopeRecipient({
userId: user.id,
teamId: team.id,
recipientId: Number(recipientId),
@ -1634,10 +1634,13 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
};
}
const { fields } = await updateDocumentFields({
const { fields } = await updateEnvelopeFields({
userId: user.id,
teamId: team.id,
documentId: legacyDocumentId,
id: {
type: 'documentId',
id: legacyDocumentId,
},
fields: [
{
id: Number(fieldId),

View File

@ -0,0 +1,741 @@
import { FieldType } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import type { TFieldAndMeta } from '@documenso/lib/types/field-meta';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
export type FieldTestData = TFieldAndMeta & {
page: number;
positionX: number;
positionY: number;
width: number;
height: number;
customText: string;
signature?: string;
};
export const signatureBase64Demo = `data:image/png;base64,${fs.readFileSync(
path.join(__dirname, '../../../packages/assets/', 'logo_icon.png'),
'base64',
)}`;
const columnWidth = 19.125;
const fullColumnWidth = 57.37499999999998;
const rowHeight = 6.7;
const rowPadding = 0;
const calculatePositionPageOne = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
) => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 19;
return {
height: rowHeight,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
const calculatePositionPageTwo = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
) => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 16.35;
return {
height: rowHeight,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
const calculatePositionPageThree = (
row: number,
column: number,
width: 'full' | 'column' = 'column',
rowQuantity: number = 1,
) => {
const alignmentGridStartX = 31;
const alignmentGridStartY = 16.4;
const rowHeight = 6.8;
return {
height: rowHeight * rowQuantity,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
/**
* Row 1 EMAIL
*/
{
type: FieldType.EMAIL,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'email',
},
page: 1,
...calculatePositionPageOne(0, 0),
customText: 'admin@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: {
textAlign: 'center',
type: 'email',
},
page: 1,
...calculatePositionPageOne(0, 1),
customText: 'admin@documenso.com',
},
{
type: FieldType.EMAIL,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'email',
},
page: 1,
...calculatePositionPageOne(0, 2),
customText: 'admin@documenso.com',
},
/**
* Row 2 NAME
*/
{
type: FieldType.NAME,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'name',
},
page: 1,
...calculatePositionPageOne(1, 0),
customText: 'John Doe',
},
{
type: FieldType.NAME,
fieldMeta: {
textAlign: 'center',
type: 'name',
},
page: 1,
...calculatePositionPageOne(1, 1),
customText: 'John Doe',
},
{
type: FieldType.NAME,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'name',
},
page: 1,
...calculatePositionPageOne(1, 2),
customText: 'John Doe',
},
/**
* Row 3 DATE
*/
{
type: FieldType.DATE,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'date',
},
page: 1,
...calculatePositionPageOne(2, 0),
customText: '123456789',
},
{
type: FieldType.DATE,
fieldMeta: {
textAlign: 'center',
type: 'date',
},
page: 1,
...calculatePositionPageOne(2, 1),
customText: '123456789',
},
{
type: FieldType.DATE,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'date',
},
page: 1,
...calculatePositionPageOne(2, 2),
customText: '123456789',
},
/**
* Row 4 TEXT
*/
{
type: FieldType.TEXT,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'text',
},
page: 1,
...calculatePositionPageOne(3, 0),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
},
page: 1,
...calculatePositionPageOne(3, 1),
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'text',
},
page: 1,
...calculatePositionPageOne(3, 2),
customText: '123456789',
},
/**
* Row 5 NUMBER
*/
{
type: FieldType.NUMBER,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'number',
},
page: 1,
...calculatePositionPageOne(4, 0),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
},
page: 1,
...calculatePositionPageOne(4, 1),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'number',
},
page: 1,
...calculatePositionPageOne(4, 2),
customText: '123456789',
},
/**
* Row 6 Initials
*/
{
type: FieldType.INITIALS,
fieldMeta: {
fontSize: 10,
textAlign: 'left',
type: 'initials',
},
page: 1,
...calculatePositionPageOne(5, 0),
customText: 'JD',
},
{
type: FieldType.INITIALS,
fieldMeta: {
textAlign: 'center',
type: 'initials',
},
page: 1,
...calculatePositionPageOne(5, 1),
customText: 'JD',
},
{
type: FieldType.INITIALS,
fieldMeta: {
fontSize: 20,
textAlign: 'right',
type: 'initials',
},
page: 1,
...calculatePositionPageOne(5, 2),
customText: 'JD',
},
/**
* Row 7 Radio
*/
{
type: FieldType.RADIO,
fieldMeta: {
fontSize: 10,
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(6, 0),
customText: '0',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(6, 1),
customText: '',
},
{
type: FieldType.RADIO,
fieldMeta: {
fontSize: 20,
direction: 'horizontal',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(6, 2),
customText: '1',
},
/**
* Row 8 Checkbox
*/
{
type: FieldType.CHECKBOX,
fieldMeta: {
fontSize: 10,
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(7, 0),
customText: toCheckboxCustomText([0]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(7, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
fontSize: 20,
direction: 'horizontal',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(7, 2),
customText: toCheckboxCustomText([1]),
},
/**
* Row 8 Dropdown
*/
{
type: FieldType.DROPDOWN,
fieldMeta: {
fontSize: 10,
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 1,
...calculatePositionPageOne(8, 0),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 1,
...calculatePositionPageOne(8, 1),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
fontSize: 20,
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
},
page: 1,
...calculatePositionPageOne(8, 2),
customText: 'Option 1',
},
/**
* Row 9 Signature
*/
{
type: FieldType.SIGNATURE,
fieldMeta: {
fontSize: 10,
type: 'signature',
},
page: 1,
...calculatePositionPageOne(9, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 1,
...calculatePositionPageOne(9, 1),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
fontSize: 20,
type: 'signature',
},
page: 1,
...calculatePositionPageOne(9, 2),
customText: '',
signature: 'My Signature',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 2
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
// TEXT GRID ROW 1
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'left',
type: 'text',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(0, 0),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(0, 1),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'right',
type: 'text',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(0, 2),
customText: 'SOME TEXT',
},
// TEXT GRID ROW 2
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'left',
type: 'text',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(1, 0),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(1, 1),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'right',
type: 'text',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(1, 2),
customText: 'SOME TEXT',
},
// TEXT GRID ROW 3
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'left',
type: 'text',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(2, 0),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'center',
type: 'text',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(2, 1),
customText: 'SOME TEXT',
},
{
type: FieldType.TEXT,
fieldMeta: {
textAlign: 'right',
type: 'text',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(2, 2),
customText: 'SOME TEXT',
},
// NUMBER GRID ROW 1
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'left',
type: 'number',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(3, 0),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(3, 1),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'right',
type: 'number',
verticalAlign: 'top',
},
page: 2,
...calculatePositionPageTwo(3, 2),
customText: '123456789123456789',
},
// NUMBER GRID ROW 2
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'left',
type: 'number',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(4, 0),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(4, 1),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'right',
type: 'number',
verticalAlign: 'middle',
},
page: 2,
...calculatePositionPageTwo(4, 2),
customText: '123456789123456789',
},
// NUMBER GRID ROW 3
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'left',
type: 'number',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(5, 0),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'center',
type: 'number',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(5, 1),
customText: '123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
textAlign: 'right',
type: 'number',
verticalAlign: 'bottom',
},
page: 2,
...calculatePositionPageTwo(5, 2),
customText: '123456789123456789',
},
// Text combing
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
verticalAlign: 'middle',
letterSpacing: 32,
characterLimit: 9,
},
page: 2,
...calculatePositionPageTwo(6, 0, 'full'),
positionX: calculatePositionPageTwo(6, 0, 'full').positionX + 1.75,
width: calculatePositionPageTwo(6, 0, 'full').width + 1.75,
customText: 'HEY HEY 1',
},
// Number combing
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
verticalAlign: 'middle',
letterSpacing: 32,
},
page: 2,
...calculatePositionPageTwo(7, 0, 'full'),
positionX: calculatePositionPageTwo(7, 0, 'full').positionX + 1.75,
width: calculatePositionPageTwo(7, 0, 'full').width + 1.75,
customText: '123456789',
},
/**
* @@@@@@@@@@@@@@@@@@@@@@@
*
* PAGE 2 TEXT MULTILINE
*
* @@@@@@@@@@@@@@@@@@@@@@@
*/
{
type: FieldType.TEXT,
fieldMeta: {
verticalAlign: 'top',
textAlign: 'left',
lineHeight: 2.24,
type: 'text',
},
page: 3,
...calculatePositionPageThree(0, 0, 'full', 3),
customText:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
},
{
type: FieldType.TEXT,
fieldMeta: {
verticalAlign: 'middle',
textAlign: 'center',
lineHeight: 2.24,
type: 'text',
},
page: 3,
...calculatePositionPageThree(3, 0, 'full', 3),
customText:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
},
{
type: FieldType.TEXT,
fieldMeta: {
verticalAlign: 'bottom',
textAlign: 'right',
lineHeight: 2.24,
type: 'text',
},
page: 3,
...calculatePositionPageThree(6, 0, 'full', 3),
customText:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
},
] as const;

View File

@ -0,0 +1,486 @@
import { FieldType } from '@prisma/client';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import {
CheckboxValidationRules,
numberFormatValues,
} from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import type { FieldTestData } from './field-alignment-pdf';
import { signatureBase64Demo } from './field-alignment-pdf';
const columnWidth = 20.1;
const fullColumnWidth = 75.8;
const rowHeight = 9.8;
const rowPadding = 1.8;
const alignmentGridStartX = 11.85;
const alignmentGridStartY = 15.07;
const calculatePosition = (row: number, column: number, width: 'full' | 'column' = 'column') => {
return {
height: rowHeight,
width: width === 'full' ? fullColumnWidth : columnWidth,
positionX: alignmentGridStartX + (column ?? 0) * columnWidth,
positionY: alignmentGridStartY + row * (rowHeight + rowPadding),
};
};
export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
/**
* PAGE 2 Signature
*/
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(0, 0),
customText: '',
signature: signatureBase64Demo,
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(1, 0),
customText: '',
signature: signatureBase64Demo,
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(2, 0),
customText: '',
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
fieldMeta: {
type: 'signature',
},
page: 2,
...calculatePosition(3, 0),
customText: '',
signature: 'My Signature super overflow maybe',
},
/**
* PAGE 3 TEXT
*/
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
},
page: 3,
...calculatePosition(0, 0, 'full'),
customText: 'Hello world, this is some random text that I have written here',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
},
page: 3,
...calculatePosition(1, 0),
customText: 'Some text that should overflow correctly',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
characterLimit: 5,
},
page: 3,
...calculatePosition(2, 0),
customText: '12345',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
placeholder: 'Demo Placeholder',
},
page: 3,
...calculatePosition(3, 0),
customText: 'Input should have a placeholder text when clicked',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
label: 'Demo Label',
},
page: 3,
...calculatePosition(3, 1),
customText: 'Should have a label during editing and signing',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
text: 'Prefilled text',
},
page: 3,
...calculatePosition(3, 2),
customText: '',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
required: true,
},
page: 3,
...calculatePosition(4, 0),
customText: 'This is a required field',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
readOnly: true,
text: 'Some Readonly Value',
},
page: 3,
...calculatePosition(4, 1),
customText: '',
},
/**
* PAGE 4 NUMBER
*/
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
},
page: 4,
...calculatePosition(0, 0, 'full'),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
},
page: 4,
...calculatePosition(1, 0),
customText: '123456789123456789123456789123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
minValue: 0,
maxValue: 100,
},
page: 4,
...calculatePosition(2, 0),
customText: '50',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
numberFormat: numberFormatValues[0].value, // Todo: Envelopes - Check this.
value: '123,456,789.00',
},
page: 4,
...calculatePosition(2, 1),
customText: '123,456,789.00',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
placeholder: 'Demo Placeholder',
},
page: 4,
...calculatePosition(3, 0),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
label: 'Demo Label',
},
page: 4,
...calculatePosition(3, 1),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
value: '123456789',
},
page: 4,
...calculatePosition(3, 2),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
required: true,
},
page: 4,
...calculatePosition(4, 0),
customText: '123456789',
},
{
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
readOnly: true,
value: '123456789',
},
page: 4,
...calculatePosition(4, 1),
customText: '',
},
/**
* PAGE 5 RADIO
*/
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'horizontal',
type: 'radio',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(0, 0, 'full'),
customText: '0',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: true, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(1, 0),
customText: '2',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
required: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(2, 0),
customText: '2',
},
{
type: FieldType.RADIO,
fieldMeta: {
direction: 'vertical',
type: 'radio',
readOnly: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: true, value: 'Option 3' },
],
},
page: 5,
...calculatePosition(2, 1),
customText: '',
},
/**
* PAGE 6 CHECKBOX
*/
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'horizontal',
type: 'checkbox',
values: [
{ id: 1, checked: true, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 3' },
{ id: 2, checked: false, value: 'Option 4' },
],
},
page: 6,
...calculatePosition(0, 0, 'full'),
customText: toCheckboxCustomText([0]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(1, 0),
customText: toCheckboxCustomText([1]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
required: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 6,
...calculatePosition(2, 0),
customText: toCheckboxCustomText([2]),
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
readOnly: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 6,
...calculatePosition(2, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_AT_LEAST,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 0),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_EXACTLY,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 1),
customText: '',
},
{
type: FieldType.CHECKBOX,
fieldMeta: {
direction: 'vertical',
type: 'checkbox',
validationRule: CheckboxValidationRules.SELECT_AT_MOST,
validationLength: 2,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 6,
...calculatePosition(3, 2),
customText: '',
},
/**
* PAGE 7 DROPDOWN
*/
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
},
page: 7,
...calculatePosition(0, 0, 'full'),
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
defaultValue: 'Option 2',
},
page: 7,
...calculatePosition(1, 0),
customText: 'Option 2',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
required: true,
},
page: 7,
...calculatePosition(2, 0),
customText: 'Option 3',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
defaultValue: 'Option 1',
readOnly: true,
},
page: 7,
...calculatePosition(2, 1),
customText: 'Option 1',
},
] as const;
export const formatFieldMetaTestFields = FIELD_META_TEST_FIELDS.map((field, index) => {
return {
...field,
};
});

View File

@ -0,0 +1,560 @@
import { expect, test } from '@playwright/test';
import type { Team, User } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { prisma } from '@documenso/prisma';
import {
DocumentDistributionMethod,
DocumentSigningOrder,
DocumentStatus,
DocumentVisibility,
EnvelopeType,
FieldType,
FolderType,
RecipientRole,
} from '@documenso/prisma/client';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
import { ALIGNMENT_TEST_FIELDS } from '../../../constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
test.describe.configure({
mode: 'parallel',
});
test.describe('API V2 Envelopes', () => {
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
test.beforeEach(async () => {
({ user: userA, team: teamA } = await seedUser());
({ token: tokenA } = await createApiToken({
userId: userA.id,
teamId: teamA.id,
tokenName: 'userA',
expiresIn: null,
}));
({ user: userB, team: teamB } = await seedUser());
({ token: tokenB } = await createApiToken({
userId: userB.id,
teamId: teamB.id,
tokenName: 'userB',
expiresIn: null,
}));
});
test.describe('Envelope create endpoint', () => {
test('should fail on invalid form', async ({ request }) => {
const payload = {
type: 'Invalid Type',
title: 'Test Envelope',
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});
test('should create envelope with single file', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Test Envelope',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUnique({
where: {
id: response.id,
},
include: {
envelopeItems: true,
},
});
expect(envelope).toBeDefined();
expect(envelope?.title).toBe('Test Envelope');
expect(envelope?.type).toBe(EnvelopeType.TEMPLATE);
expect(envelope?.status).toBe(DocumentStatus.DRAFT);
expect(envelope?.envelopeItems.length).toBe(1);
expect(envelope?.envelopeItems[0].title).toBe('field-font-alignment.pdf');
expect(envelope?.envelopeItems[0].documentDataId).toBeDefined();
});
test('should create envelope with multiple file', async ({ request }) => {
const folder = await prisma.folder.create({
data: {
name: 'Test Folder',
teamId: teamA.id,
userId: userA.id,
type: FolderType.DOCUMENT,
},
});
const payload = {
title: 'Envelope Title',
type: EnvelopeType.DOCUMENT,
externalId: 'externalId',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
formValues: {
hello: 'world',
},
folderId: folder.id,
recipients: [
{
email: userA.email,
name: 'Name',
role: RecipientRole.SIGNER,
accessAuth: ['TWO_FACTOR_AUTH'],
signingOrder: 1,
fields: [
{
type: FieldType.SIGNATURE,
identifier: 'field-font-alignment.pdf',
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
{
type: FieldType.SIGNATURE,
identifier: 0,
page: 1,
positionX: 0,
positionY: 0,
width: 0,
height: 0,
},
],
},
],
meta: {
subject: 'Subject',
message: 'Message',
timezone: 'Europe/Berlin',
dateFormat: 'dd.MM.yyyy',
distributionMethod: DocumentDistributionMethod.NONE,
signingOrder: DocumentSigningOrder.SEQUENTIAL,
allowDictateNextSigner: true,
redirectUrl: 'https://documenso.com',
language: 'de',
typedSignatureEnabled: true,
uploadSignatureEnabled: false,
drawSignatureEnabled: false,
emailReplyTo: userA.email,
emailSettings: {
recipientSigningRequest: false,
recipientRemoved: false,
recipientSigned: false,
documentPending: false,
documentCompleted: false,
documentDeleted: false,
ownerDocumentCompleted: true,
},
},
attachments: [
{
label: 'Test Attachment',
data: 'https://documenso.com',
type: 'link',
},
],
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'field-meta.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/field-meta.pdf')),
},
{
name: 'field-font-alignment.pdf',
data: fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
// Should error since folder is not owned by the user.
const invalidRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
});
expect(invalidRes.ok()).toBeFalsy();
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: response.id,
},
include: {
documentMeta: true,
envelopeItems: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
});
console.log(userB.email);
expect(envelope.envelopeItems.length).toBe(2);
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
expect(envelope.title).toBe(payload.title);
expect(envelope.type).toBe(payload.type);
expect(envelope.externalId).toBe(payload.externalId);
expect(envelope.visibility).toBe(payload.visibility);
expect(envelope.authOptions).toEqual({
globalAccessAuth: payload.globalAccessAuth,
globalActionAuth: [],
});
expect(envelope.formValues).toEqual(payload.formValues);
expect(envelope.folderId).toBe(payload.folderId);
expect(envelope.documentMeta.subject).toBe(payload.meta.subject);
expect(envelope.documentMeta.message).toBe(payload.meta.message);
expect(envelope.documentMeta.timezone).toBe(payload.meta.timezone);
expect(envelope.documentMeta.dateFormat).toBe(payload.meta.dateFormat);
expect(envelope.documentMeta.distributionMethod).toBe(payload.meta.distributionMethod);
expect(envelope.documentMeta.signingOrder).toBe(payload.meta.signingOrder);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(
payload.meta.allowDictateNextSigner,
);
expect(envelope.documentMeta.redirectUrl).toBe(payload.meta.redirectUrl);
expect(envelope.documentMeta.language).toBe(payload.meta.language);
expect(envelope.documentMeta.typedSignatureEnabled).toBe(payload.meta.typedSignatureEnabled);
expect(envelope.documentMeta.uploadSignatureEnabled).toBe(
payload.meta.uploadSignatureEnabled,
);
expect(envelope.documentMeta.drawSignatureEnabled).toBe(payload.meta.drawSignatureEnabled);
expect(envelope.documentMeta.emailReplyTo).toBe(payload.meta.emailReplyTo);
expect(envelope.documentMeta.emailSettings).toEqual(payload.meta.emailSettings);
expect([
{
label: envelope.envelopeAttachments[0].label,
data: envelope.envelopeAttachments[0].data,
type: envelope.envelopeAttachments[0].type,
},
]).toEqual(payload.attachments);
const field = envelope.fields[0];
const recipient = envelope.recipients[0];
expect({
email: recipient.email,
name: recipient.name,
role: recipient.role,
signingOrder: recipient.signingOrder,
accessAuth: recipient.authOptions?.accessAuth,
}).toEqual(
pick(payload.recipients[0], ['email', 'name', 'role', 'signingOrder', 'accessAuth']),
);
expect({
type: field.type,
page: field.page,
positionX: field.positionX.toNumber(),
positionY: field.positionY.toNumber(),
width: field.width.toNumber(),
height: field.height.toNumber(),
}).toEqual(
pick(payload.recipients[0].fields[0], [
'type',
'page',
'positionX',
'positionY',
'width',
'height',
]),
);
// Expect string based ID to work.
expect(field.envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-font-alignment.pdf')?.id,
);
// Expect index based ID to work.
expect(envelope.fields[1].envelopeItemId).toBe(
envelope.envelopeItems.find((item) => item.title === 'field-meta.pdf')?.id,
);
});
});
/**
* Creates envelopes with the two field test PDFs.
*/
test('Envelope full test', async ({ request }) => {
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-font-alignment.pdf'),
);
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const formData = new FormData();
formData.append(
'payload',
JSON.stringify({
type: EnvelopeType.DOCUMENT,
title: 'Envelope Full Field Test',
} satisfies TCreateEnvelopePayload),
);
// Only add one file for now.
formData.append(
'files',
new File([alignmentPdf], 'field-font-alignment.pdf', { type: 'application/pdf' }),
);
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const getEnvelopeRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const createdEnvelope: TGetEnvelopeResponse = await getEnvelopeRequest.json();
// Might as well testing access control here as well.
const unauthRequest = await request.get(`${baseUrl}/envelope/${createdEnvelopeId}`, {
headers: { Authorization: `Bearer ${tokenB}` },
});
expect(unauthRequest.ok()).toBeFalsy();
expect(unauthRequest.status()).toBe(404);
// Step 2: Create second envelope item via API
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
envelopeId: createdEnvelope.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`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: createEnvelopeItemFormData,
});
expect(createItemsRes.ok()).toBeTruthy();
expect(createItemsRes.status()).toBe(200);
// Step 3: Update envelope via API
const updateEnvelopeRequest: TUpdateEnvelopeRequest = {
envelopeId: createdEnvelope.id,
data: {
title: 'Envelope Full Field Test',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
};
const updateRes = await request.post(`${baseUrl}/envelope/update`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: updateEnvelopeRequest,
});
expect(updateRes.ok()).toBeTruthy();
expect(updateRes.status()).toBe(200);
// Step 4: Create recipient via API
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createdEnvelope.id,
data: [
{
email: userA.email,
name: userA.name || '',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
expect(createRecipientsRes.status()).toBe(200);
// Step 5: Get envelope to retrieve recipients and envelope items
const getRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(getRes.ok()).toBeTruthy();
expect(getRes.status()).toBe(200);
const envelopeResponse = (await getRes.json()) as TGetEnvelopeResponse;
const recipientId = envelopeResponse.recipients[0].id;
const alignmentItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 1,
);
const fieldMetaItem = envelopeResponse.envelopeItems.find(
(item: { order: number }) => item.order === 2,
);
expect(recipientId).toBeDefined();
expect(alignmentItem).toBeDefined();
expect(fieldMetaItem).toBeDefined();
if (!alignmentItem || !fieldMetaItem) {
throw new Error('Envelope items not found');
}
// Step 6: Create fields for first PDF (alignment fields)
const alignmentFieldsRequest = {
envelopeId: createdEnvelope.id,
data: ALIGNMENT_TEST_FIELDS.map((field) => ({
recipientId,
envelopeItemId: alignmentItem.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
})),
};
const createAlignmentFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: alignmentFieldsRequest,
});
expect(createAlignmentFieldsRes.ok()).toBeTruthy();
expect(createAlignmentFieldsRes.status()).toBe(200);
// Step 7: Create fields for second PDF (field-meta fields)
const fieldMetaFieldsRequest = {
envelopeId: createdEnvelope.id,
data: FIELD_META_TEST_FIELDS.map((field) => ({
recipientId,
envelopeItemId: fieldMetaItem.id,
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
})),
};
const createFieldMetaFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: fieldMetaFieldsRequest,
});
expect(createFieldMetaFieldsRes.ok()).toBeTruthy();
expect(createFieldMetaFieldsRes.status()).toBe(200);
// Step 8: Verify final envelope structure
const finalGetRes = await request.get(`${baseUrl}/envelope/${createdEnvelope.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
expect(finalGetRes.ok()).toBeTruthy();
const finalEnvelope = (await finalGetRes.json()) as TGetEnvelopeResponse;
// Verify structure
expect(finalEnvelope.envelopeItems.length).toBe(2);
expect(finalEnvelope.recipients.length).toBe(1);
expect(finalEnvelope.fields.length).toBe(
ALIGNMENT_TEST_FIELDS.length + FIELD_META_TEST_FIELDS.length,
);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
console.log({
createdEnvelopeId: finalEnvelope.id,
userEmail: userA.email,
});
});
});

Some files were not shown because too many files have changed in this diff Show More