Compare commits

..

20 Commits

Author SHA1 Message Date
fc2e9af6a0 fix: add preview page 2025-11-05 17:18:15 +11:00
a810d20a4f chore: update package lock 2025-11-05 16:42:42 +11:00
22011fd4ba fix: finish file stuff 2025-11-05 14:51:07 +11:00
717fa8f870 fix: add endpoints for getting files 2025-11-04 15:18:11 +11:00
8663c8f883 fix: various envelope updates 2025-11-04 14:57:42 +11:00
c89ca83f44 fix: redirect v2 beta url 2025-11-04 11:55:07 +11:00
bbf1dd3c6b fix: add tests 2025-11-03 20:30:35 +11:00
c10c95ca00 fix: add tests 2025-11-03 20:17:52 +11:00
4a0425b120 feat: add formdata endpoints for documents,envelopes,templates
Adds the missing endpoints for documents, envelopes and
templates supporting file uploads in a singular request.

Also updates frontend components that would use the prior
hidden endpoints.
2025-11-03 15:11:20 +11:00
a6e923dd8a feat: allow multipart requests for public api
Adds support for multipart/form-data requests in the public api
allowing documents to be uploaded without having to perform a secondary
request.

Need to rollout further endpoints for envelopes and templates.

Need to change how we store files to not use `putFileServerSide`
2025-11-03 15:10:28 +11:00
7e38d06ef5 Merge branch 'main' into feat/add-envelopes-api 2025-11-01 12:47:55 +11:00
4e2443396c fix: increase res 2025-10-31 20:49:57 +11:00
2e2980f04f fix: increase res 2025-10-31 20:28:45 +11:00
3efe0de52f fix: increase threshold 2025-10-31 17:43:33 +11:00
efbd133f0e fix: increase threshold 2025-10-31 17:21:33 +11:00
4993e8a306 fix: test 2025-10-31 17:06:59 +11:00
f93d34c38e fix: clean up endpoints 2025-10-31 15:48:05 +11:00
8c228f965a fix: test 2025-10-31 15:06:20 +11:00
9020bbc753 fix: add regression test 2025-10-31 12:38:14 +11:00
f6bdb34b56 feat: add envelopes api 2025-10-28 20:32:24 +11:00
198 changed files with 5033 additions and 9830 deletions

View File

@ -49,20 +49,20 @@ export const DocumentDuplicateDialog = ({
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpcReact.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
onSuccess: async ({ duplicatedEnvelopeId }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${id}/edit`);
await navigate(`${documentsPath}/${duplicatedEnvelopeId}/edit`);
onOpenChange(false);
},
});

View File

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

View File

@ -61,12 +61,12 @@ export const EnvelopeDownloadDialog = ({
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
initialData: initialEnvelopeItems ? { envelopeItems: initialEnvelopeItems } : undefined,
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const envelopeItems = envelopeItemsPayload?.envelopeItems || [];
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,

View File

@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
onSuccess: async ({ duplicatedEnvelopeId }) => {
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}/${id}/edit`);
await navigate(`${path}/${duplicatedEnvelopeId}/edit`);
setOpen(false);
},
});

View File

@ -185,10 +185,6 @@ 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,4 +1,5 @@
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';
@ -27,71 +28,49 @@ 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, string | null>(
export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps, number | 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

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

View File

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

View File

@ -336,7 +336,7 @@ export const EmbedDirectTemplateClientPage = ({
<div className="flex-1">
<PDFViewer
envelopeItem={envelopeItems[0]}
token={recipient.token}
token={token}
version="signed"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>

View File

@ -1,232 +0,0 @@
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

@ -40,7 +40,7 @@ import { EmbedDocumentCompleted } from './embed-document-completed';
import { EmbedDocumentFields } from './embed-document-fields';
import { EmbedDocumentRejected } from './embed-document-rejected';
export type EmbedSignDocumentV1ClientPageProps = {
export type EmbedSignDocumentClientPageProps = {
token: string;
documentId: number;
envelopeId: string;
@ -55,7 +55,7 @@ export type EmbedSignDocumentV1ClientPageProps = {
allRecipients?: RecipientWithFields[];
};
export const EmbedSignDocumentV1ClientPage = ({
export const EmbedSignDocumentClientPage = ({
token,
documentId,
envelopeId,
@ -68,7 +68,7 @@ export const EmbedSignDocumentV1ClientPage = ({
hidePoweredBy = false,
allowWhitelabelling = false,
allRecipients = [],
}: EmbedSignDocumentV1ClientPageProps) => {
}: EmbedSignDocumentClientPageProps) => {
const { _ } = useLingui();
const { toast } = useToast();

View File

@ -1,101 +0,0 @@
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

@ -7,7 +7,6 @@ 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';
@ -40,7 +39,7 @@ export const EditorFieldDateForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -7,7 +7,6 @@ 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';
@ -40,7 +39,7 @@ export const EditorFieldEmailForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -3,10 +3,6 @@ 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 {
@ -111,119 +107,6 @@ 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,7 +6,6 @@ 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';
@ -40,7 +39,7 @@ export const EditorFieldInitialsForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -6,7 +6,6 @@ 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';
@ -40,7 +39,7 @@ export const EditorFieldNameForm = ({
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
textAlign: value.textAlign ?? FIELD_DEFAULT_GENERIC_ALIGN,
textAlign: value.textAlign || 'left',
},
});

View File

@ -6,11 +6,6 @@ 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';
@ -36,12 +31,9 @@ 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({
@ -51,9 +43,6 @@ const ZNumberFieldFormSchema = ZNumberFieldMeta.pick({
numberFormat: true,
fontSize: true,
textAlign: true,
lineHeight: true,
letterSpacing: true,
verticalAlign: true,
required: true,
readOnly: true,
minValue: true,
@ -110,11 +99,8 @@ export const EditorFieldNumberForm = ({
placeholder: value.placeholder || '',
value: value.value || '',
numberFormat: value.numberFormat || null,
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,
fontSize: value.fontSize || 14,
textAlign: value.textAlign || 'left',
required: value.required || false,
readOnly: value.readOnly || false,
minValue: value.minValue,
@ -132,10 +118,6 @@ export const EditorFieldNumberForm = ({
useEffect(() => {
const validatedFormValues = ZNumberFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.value) {
void form.trigger('value');
}
if (validatedFormValues.success) {
onValueChange({
type: 'number',
@ -148,12 +130,10 @@ export const EditorFieldNumberForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<EditorGenericLabelField formControl={form.control} />
@ -224,12 +204,6 @@ 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,8 +5,11 @@ import { Trans } from '@lingui/react/macro';
import { useForm, useWatch } from 'react-hook-form';
import type { z } from 'zod';
import { DEFAULT_SIGNATURE_TEXT_FONT_SIZE } from '@documenso/lib/constants/pdf';
import { type TSignatureFieldMeta, ZSignatureFieldMeta } from '@documenso/lib/types/field-meta';
import {
DEFAULT_FIELD_FONT_SIZE,
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';
@ -32,7 +35,7 @@ export const EditorFieldSignatureForm = ({
resolver: zodResolver(ZSignatureFieldFormSchema),
mode: 'onChange',
defaultValues: {
fontSize: value.fontSize || DEFAULT_SIGNATURE_TEXT_FONT_SIZE,
fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE,
},
});

View File

@ -3,16 +3,11 @@ 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 type { z } from 'zod';
import { 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,
@ -27,36 +22,32 @@ import { Textarea } from '@documenso/ui/primitives/textarea';
import {
EditorGenericFontSizeField,
EditorGenericLetterSpacingField,
EditorGenericLineHeightField,
EditorGenericReadOnlyField,
EditorGenericRequiredField,
EditorGenericTextAlignField,
EditorGenericVerticalAlignField,
} from './editor-field-generic-field-forms';
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'],
},
);
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'],
},
);
type TTextFieldFormSchema = z.infer<typeof ZTextFieldFormSchema>;
@ -82,10 +73,7 @@ export const EditorFieldTextForm = ({
text: value.text || '',
characterLimit: value.characterLimit || 0,
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,
textAlign: value.textAlign || 'left',
required: value.required || false,
readOnly: value.readOnly || false,
},
@ -101,10 +89,6 @@ export const EditorFieldTextForm = ({
useEffect(() => {
const validatedFormValues = ZTextFieldFormSchema.safeParse(formValues);
if (formValues.readOnly && !formValues.text) {
void form.trigger('text');
}
if (validatedFormValues.success) {
onValueChange({
type: 'text',
@ -117,12 +101,10 @@ export const EditorFieldTextForm = ({
<Form {...form}>
<form>
<fieldset className="flex flex-col gap-2">
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<div className="flex w-full flex-row gap-x-4">
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
<EditorGenericFontSizeField className="w-full" formControl={form.control} />
<EditorGenericVerticalAlignField className="w-full" formControl={form.control} />
<EditorGenericTextAlignField className="w-full" formControl={form.control} />
</div>
<FormField
@ -200,16 +182,17 @@ export const EditorFieldTextForm = ({
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
className="bg-background"
placeholder={t`Character limit`}
placeholder={t`Field character limit`}
{...field}
value={field.value || ''}
onChange={(e) => {
field.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) {
@ -223,12 +206,6 @@ 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,7 +92,6 @@ export const SignInForm = ({
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const [isEmbeddedRedirect, setIsEmbeddedRedirect] = useState(false);
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
@ -318,8 +317,6 @@ export const SignInForm = ({
if (email) {
form.setValue('email', email);
}
setIsEmbeddedRedirect(params.get('embedded') === 'true');
}, [form]);
return (
@ -386,64 +383,56 @@ export const SignInForm = ({
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
{!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>
)}
{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,7 +68,6 @@ export type SignUpFormProps = {
isGoogleSSOEnabled?: boolean;
isMicrosoftSSOEnabled?: boolean;
isOIDCSSOEnabled?: boolean;
returnTo?: string;
};
export const SignUpForm = ({
@ -77,7 +76,6 @@ export const SignUpForm = ({
isGoogleSSOEnabled,
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
returnTo,
}: SignUpFormProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@ -112,7 +110,7 @@ export const SignUpForm = ({
signature,
});
await navigate(returnTo ? returnTo : '/unverified-account');
await navigate(`/unverified-account`);
toast({
title: _(msg`Registration Successful`),

View File

@ -9,7 +9,6 @@ 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 {
@ -64,12 +63,10 @@ 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: debouncedSearch,
query: search,
},
{
placeholderData: (previousData) => previousData,
@ -235,7 +232,6 @@ export function AppCommandMenu({ open, onOpenChange }: AppCommandMenuProps) {
<Trans>No results found.</Trans>
</CommandEmpty>
)}
{!currentPage && (
<>
{documentPageLinks.length > 0 && (
@ -243,17 +239,14 @@ 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
@ -262,7 +255,6 @@ 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

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

View File

@ -47,8 +47,7 @@ export const DocumentSigningAuthDialog = ({
onOpenChange,
onReauthFormSubmit,
}: DocumentSigningAuthDialogProps) => {
const { recipient, user, isCurrentlyAuthenticating, isDirectTemplate } =
useRequiredDocumentSigningAuthContext();
const { recipient, user, isCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext();
// Filter out EXPLICIT_NONE from available auth types for the chooser
const validAuthTypes = availableAuthTypes.filter(
@ -169,11 +168,7 @@ export const DocumentSigningAuthDialog = ({
match({ documentAuthType: selectedAuthType, user })
.with(
{ documentAuthType: DocumentAuth.ACCOUNT },
{
user: P.when(
(user) => !user || (user.email !== recipient.email && !isDirectTemplate),
),
}, // Assume all current auth methods requires them to be logged in.
{ user: P.when((user) => !user || user.email !== recipient.email) }, // Assume all current auth methods requires them to be logged in.
() => <DocumentSigningAuthAccount onOpenChange={onOpenChange} />,
)
.with({ documentAuthType: DocumentAuth.PASSKEY }, () => (

View File

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

View File

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

View File

@ -8,9 +8,6 @@ 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';
@ -18,8 +15,6 @@ import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider';
export const DocumentSigningMobileWidget = () => {
const [isExpanded, setIsExpanded] = useState(false);
const { hidePoweredBy = true } = useEmbedSigningContext() || {};
const { recipientFieldsRemaining, recipient, requiredRecipientFields } =
useRequiredEnvelopeSigningContext();
@ -34,7 +29,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-[760px]">
<div className="pointer-events-auto w-full max-w-2xl">
<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">
@ -119,13 +114,6 @@ 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

@ -22,9 +22,7 @@ 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';
@ -50,13 +48,6 @@ 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.
*
@ -86,7 +77,7 @@ export const DocumentSigningPageViewV2 = () => {
{/* Main Content Area */}
<div className="flex h-[calc(100vh-4rem)] w-screen">
{/* Left Section - Step Navigation */}
<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="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)
@ -116,7 +107,7 @@ export const DocumentSigningPageViewV2 = () => {
/>
</div>
<div className="embed--DocumentWidgetContent mt-6 space-y-3">
<div className="mt-6 space-y-3">
<EnvelopeSignerForm />
</div>
</div>
@ -125,7 +116,7 @@ export const DocumentSigningPageViewV2 = () => {
{/* Quick Actions. */}
{!isDirectTemplate && (
<div className="embed--Actions space-y-3 px-4">
<div className="space-y-3 px-4">
<h4 className="text-foreground text-sm font-semibold">
<Trans>Actions</Trans>
</h4>
@ -154,21 +145,10 @@ export const DocumentSigningPageViewV2 = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection && (
{envelope.type === EnvelopeType.DOCUMENT && (
<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"
@ -184,22 +164,18 @@ export const DocumentSigningPageViewV2 = () => {
</div>
)}
<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>
)}
{/* 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>
</div>
<div className="embed--DocumentContainer flex-1 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col">
{/* Horizontal envelope item selector */}
{envelopeItems.length > 1 && (
@ -226,11 +202,12 @@ export const DocumentSigningPageViewV2 = () => {
)}
{/* Document View */}
<div className="embed--DocumentViewer flex flex-col items-center justify-center p-2 sm:mt-4 sm:p-4">
<div className="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}
/>
) : (
@ -242,20 +219,9 @@ export const DocumentSigningPageViewV2 = () => {
)}
{/* Mobile widget - Additional padding to allow users to scroll */}
<div className="block pb-28 lg:hidden">
<div className="block pb-16 md: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

@ -56,7 +56,7 @@ export type EnvelopeSigningContextValue = {
_fieldId: number,
_value: TSignEnvelopeFieldValue,
authOptions?: TRecipientActionAuth,
) => Promise<Pick<Field, 'id' | 'inserted'>>;
) => Promise<void>;
};
const EnvelopeSigningContext = createContext<EnvelopeSigningContextValue | null>(null);
@ -296,19 +296,16 @@ export const EnvelopeSigningProvider = ({
) => {
// Set the field locally for direct templates.
if (isDirectTemplate) {
const signedField = handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return signedField;
handleDirectTemplateFieldInsertion(fieldId, fieldValue);
return;
}
const { signedField } = await signEnvelopeField({
await signEnvelopeField({
token: envelopeData.recipient.token,
fieldId,
fieldValue,
authOptions,
});
return signedField;
};
const handleDirectTemplateFieldInsertion = (
@ -366,8 +363,6 @@ export const EnvelopeSigningProvider = ({
fields: prev.recipient.fields.map((field) => (field.id === fieldId ? updatedField : field)),
},
}));
return updatedField;
};
return (

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client';
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import { DateTime } from 'luxon';
@ -100,14 +100,7 @@ export const DocumentCertificateQRView = ({
)}
{internalVersion === 2 ? (
<EnvelopeRenderProvider
envelope={{
envelopeItems,
status: DocumentStatus.COMPLETED,
type: EnvelopeType.DOCUMENT,
}}
token={token}
>
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
@ -137,7 +130,7 @@ export const DocumentCertificateQRView = ({
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<Button type="button" variant="outline" className="flex-1">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
@ -196,7 +189,7 @@ const DocumentCertificateQrV2 = ({
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="w-fit">
<Button type="button" variant="outline" className="flex-1">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>

View File

@ -7,7 +7,6 @@ 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;
@ -41,10 +40,6 @@ 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

@ -616,14 +616,13 @@ export default function EnvelopeEditorFieldsPageRenderer() {
transform: 'translateX(-50%)',
zIndex: 50,
}}
// 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"
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"
>
{fieldButtonList.map((field) => (
<button
key={field.type}
onClick={() => createFieldFromPendingTemplate(pendingFieldCreation, field.type)}
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"
className="hover:text-foreground col-span-1 w-full flex-shrink-0 rounded-sm px-2 py-1 text-xs hover:bg-gray-100"
>
{t(field.name)}
</button>

View File

@ -2,7 +2,7 @@ import { lazy, useEffect, useMemo, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { match } from 'ts-pattern';
@ -201,10 +201,7 @@ export const EnvelopeEditorPreviewPage = () => {
envelope={envelope}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients.map((recipient) => ({
...recipient,
signingStatus: SigningStatus.SIGNED,
}))}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
overrideSettings={{
mode: 'export',
}}

View File

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

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, editorFields } = useCurrentEnvelopeEditor();
const { envelope, setLocalEnvelope, relativePath } = 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.filter(
onSuccess: (data) => {
const createdEnvelopes = data.createdEnvelopeItems.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.find((item) => item.id === originalItem.id);
const updatedItem = data.updatedEnvelopeItems.find((item) => item.id === originalItem.id);
if (updatedItem) {
return {
@ -114,19 +114,36 @@ export const EnvelopeEditorUploadPage = () => {
setLocalFiles((prev) => [...prev, ...newUploadingFiles]);
const payload = {
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({
envelopeId: envelope.id,
} 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) => {
data: envelopeItemsToCreate,
}).catch((error) => {
console.error(error);
// Set error state on files in batch upload.
@ -148,7 +165,7 @@ export const EnvelopeEditorUploadPage = () => {
);
return filteredFiles.concat(
data.map((item) => ({
createdEnvelopeItems.map((item) => ({
id: item.id,
envelopeItemId: item.id,
title: item.title,
@ -165,17 +182,9 @@ 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);
};
/**

View File

@ -29,7 +29,7 @@ export const EnvelopeItemSelector = ({
{...buttonProps}
>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-medium ${
className={`flex h-6 w-6 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,7 +1,6 @@
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';
@ -9,24 +8,12 @@ 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 {
envelopeStatus,
currentEnvelopeItem,
fields,
recipients,
getRecipientColorKey,
setRenderError,
overrideSettings,
} = useCurrentEnvelopeRender();
const { currentEnvelopeItem, fields, getRecipientColorKey, setRenderError, overrideSettings } =
useCurrentEnvelopeRender();
const {
stage,
@ -42,47 +29,21 @@ export default function EnvelopeGenericPageRenderer() {
const { _className, scale } = pageContext;
const localPageFields = useMemo((): GenericLocalField[] => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return [];
}
return fields
.filter(
const localPageFields = useMemo(
() =>
fields.filter(
(field) =>
field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id,
)
.map((field) => {
const recipient = recipients.find((recipient) => recipient.id === field.recipientId);
),
[fields, pageContext.pageNumber],
);
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) => {
const unsafeRenderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
if (!pageLayer.current) {
console.error('Layer not loaded yet');
return;
}
const fieldTranslations = getClientSideFieldTranslations(i18n);
renderField({
scale,
pageLayer: pageLayer.current,
@ -93,22 +54,19 @@ export default function EnvelopeGenericPageRenderer() {
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
customText: field.inserted ? field.customText : '',
fieldMeta: field.fieldMeta,
signature: {
signatureImageAsBase64: '',
typedSignature: fieldTranslations.SIGNATURE,
},
},
translations: fieldTranslations,
translations: getClientSideFieldTranslations(i18n),
pageWidth: unscaledViewport.width,
pageHeight: unscaledViewport.height,
color: getRecipientColorKey(field.recipientId),
editable: false,
mode: overrideSettings?.mode ?? 'edit',
mode: overrideSettings?.mode ?? 'sign',
});
};
const renderFieldOnLayer = (field: GenericLocalField) => {
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
@ -164,16 +122,6 @@ 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,8 +8,6 @@ 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() {
@ -27,8 +25,6 @@ export default function EnvelopeSignerForm() {
setSelectedAssistantRecipientId,
} = useRequiredEnvelopeSigningContext();
const { isNameLocked, isEmailLocked } = useEmbedSigningContext() || {};
const hasSignatureField = useMemo(() => {
return recipientFields.some((field) => field.type === FieldType.SIGNATURE);
}, [recipientFields]);
@ -41,7 +37,7 @@ export default function EnvelopeSignerForm() {
if (recipient.role === RecipientRole.ASSISTANT) {
return (
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
<fieldset className="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()}
@ -105,8 +101,7 @@ export default function EnvelopeSignerForm() {
id="full-name"
className="bg-background mt-2"
value={fullName}
disabled={isNameLocked}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>

View File

@ -16,7 +16,6 @@ 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';
@ -29,7 +28,7 @@ export const EnvelopeSignerHeader = () => {
useRequiredEnvelopeSigningContext();
return (
<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">
<nav className="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">
@ -73,7 +72,7 @@ export const EnvelopeSignerHeader = () => {
</div>
{/* Right side - Desktop content */}
<div className="hidden items-center space-x-2 lg:flex">
<div className="hidden items-center space-x-2 md:flex">
<p className="text-muted-foreground mr-2 flex-shrink-0 text-sm">
<Plural
one="1 Field Remaining"
@ -86,7 +85,7 @@ export const EnvelopeSignerHeader = () => {
</div>
{/* Mobile Actions button */}
<div className="flex-shrink-0 lg:hidden">
<div className="flex-shrink-0 md:hidden">
<MobileDropdownMenu />
</div>
</nav>
@ -96,8 +95,6 @@ export const EnvelopeSignerHeader = () => {
const MobileDropdownMenu = () => {
const { envelope, recipient } = useRequiredEnvelopeSigningContext();
const { allowDocumentRejection } = useEmbedSigningContext() || {};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -122,7 +119,7 @@ const MobileDropdownMenu = () => {
}
/>
{envelope.type === EnvelopeType.DOCUMENT && allowDocumentRejection !== false && (
{envelope.type === EnvelopeType.DOCUMENT && (
<DocumentSigningRejectDialog
documentId={mapSecondaryIdToDocumentId(envelope.secondaryId)}
token={recipient.token}

View File

@ -1,14 +1,7 @@
import { useEffect, useMemo } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import {
type Field,
FieldType,
type Recipient,
RecipientRole,
type Signature,
SigningStatus,
} from '@prisma/client';
import { type Field, FieldType, RecipientRole, type Signature } from '@prisma/client';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
import { match } from 'ts-pattern';
@ -17,9 +10,7 @@ import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-rende
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { 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';
@ -27,12 +18,10 @@ import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields
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';
@ -45,10 +34,6 @@ 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 { t, i18n } = useLingui();
const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender();
@ -75,8 +60,6 @@ export default function EnvelopeSignerPageRenderer() {
isDirectTemplate,
} = useRequiredEnvelopeSigningContext();
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const {
stage,
pageLayer,
@ -104,36 +87,6 @@ export default function EnvelopeSignerPageRenderer() {
);
}, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]);
/**
* 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');
@ -419,65 +372,13 @@ export default function EnvelopeSignerPageRenderer() {
}
};
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 });
}
await signFieldInternal(fieldId, payload, authOptions);
} catch (err) {
console.error(err);
@ -495,7 +396,11 @@ export default function EnvelopeSignerPageRenderer() {
* Initialize the Konva page canvas and all fields and interactions.
*/
const createPageCanvas = (currentStage: Konva.Stage, currentPageLayer: Konva.Layer) => {
renderFields();
// Render the fields.
for (const field of localPageFields) {
renderFieldOnLayer(field);
}
currentPageLayer.batchDraw();
};
@ -507,7 +412,10 @@ export default function EnvelopeSignerPageRenderer() {
return;
}
renderFields();
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
}, [localPageFields, showPendingFieldTooltip, fullName, signature, email]);
@ -523,7 +431,9 @@ export default function EnvelopeSignerPageRenderer() {
// Rerender the whole page.
pageLayer.current.destroyChildren();
renderFields();
localPageFields.forEach((field) => {
renderFieldOnLayer(field);
});
pageLayer.current.batchDraw();
}, [selectedAssistantRecipient]);
@ -550,15 +460,6 @@ 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,19 +2,16 @@ import { useMemo } from 'react';
import { useLingui } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { useNavigate, useRevalidator, useSearchParams } from 'react-router';
import { useNavigate, 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';
@ -22,9 +19,8 @@ export const EnvelopeSignerCompleteDialog = () => {
const navigate = useNavigate();
const analytics = useAnalytics();
const { t } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { t } = useLingui();
const [searchParams] = useSearchParams();
@ -41,8 +37,6 @@ export const EnvelopeSignerCompleteDialog = () => {
const { currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender();
const { onDocumentCompleted, onDocumentError } = useEmbedSigningContext() || {};
const { mutateAsync: completeDocument, isPending } =
trpc.recipient.completeDocumentWithToken.useMutation();
@ -74,54 +68,25 @@ export const EnvelopeSignerCompleteDialog = () => {
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
) => {
try {
const payload = {
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
authOptions: accessAuthOptions,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
};
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 (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;
if (envelope.documentMeta.redirectUrl) {
window.location.href = envelope.documentMeta.redirectUrl;
} else {
await navigate(`/sign/${recipient.token}/complete`);
}
};
@ -140,12 +105,8 @@ export const EnvelopeSignerCompleteDialog = () => {
directTemplateExternalId = decodeURIComponent(directTemplateExternalId);
}
if (!recipient.directToken) {
throw new Error('Recipient direct token is required');
}
const { token } = await createDocumentFromDirectTemplate({
directTemplateToken: recipient.directToken, // The direct template token is inserted into the recipient token for ease of use.
directTemplateToken: recipient.token, // The direct template token is inserted into the recipient token for ease of use.
directTemplateExternalId,
directRecipientName: recipientDetails?.name || fullName,
directRecipientEmail: recipientDetails?.email || email,
@ -171,31 +132,18 @@ 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

@ -7,13 +7,11 @@ 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'>;
@ -45,10 +43,6 @@ 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

@ -148,12 +148,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
overrideSettings={{
showRecipientSigningStatus: true,
showRecipientTooltip: true,
}}
fields={envelope.status == DocumentStatus.COMPLETED ? [] : envelope.fields}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />

View File

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

View File

@ -172,10 +172,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
overrideSettings={{
showRecipientTooltip: true,
}}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />

View File

@ -184,7 +184,6 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV
<DocumentSigningAuthProvider
documentAuthOptions={template.authOptions}
recipient={directTemplateRecipient}
isDirectTemplate={true}
user={user}
>
<>

View File

@ -1,5 +1,3 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { Link, redirect } from 'react-router';
@ -11,7 +9,6 @@ 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';
@ -31,12 +28,8 @@ 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(returnTo || '/');
throw redirect('/');
}
return {
@ -44,28 +37,12 @@ export async function loader({ request }: Route.LoaderArgs) {
isMicrosoftSSOEnabled,
isOIDCSSOEnabled,
oidcProviderLabel,
returnTo,
};
}
export default function SignIn({ loaderData }: Route.ComponentProps) {
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');
}, []);
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData;
return (
<div className="w-screen max-w-lg px-4">
@ -84,17 +61,13 @@ export default function SignIn({ loaderData }: Route.ComponentProps) {
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
oidcProviderLabel={oidcProviderLabel}
returnTo={returnTo}
/>
{!isEmbeddedRedirect && env('NEXT_PUBLIC_DISABLE_SIGNUP') !== 'true' && (
{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={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'}
className="text-documenso-700 duration-200 hover:opacity-70"
>
<Link to="/signup" className="text-documenso-700 duration-200 hover:opacity-70">
Sign up
</Link>
</Trans>

View File

@ -6,7 +6,6 @@ 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';
@ -17,7 +16,7 @@ export function meta() {
return appMetaTags('Sign Up');
}
export function loader({ request }: Route.LoaderArgs) {
export function loader() {
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');
// SSR env variables.
@ -29,20 +28,15 @@ export function loader({ request }: Route.LoaderArgs) {
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, returnTo } = loaderData;
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled } = loaderData;
return (
<SignUpForm
@ -50,7 +44,6 @@ export default function SignUp({ loaderData }: Route.ComponentProps) {
isGoogleSSOEnabled={isGoogleSSOEnabled}
isMicrosoftSSOEnabled={isMicrosoftSSOEnabled}
isOIDCSSOEnabled={isOIDCSSOEnabled}
returnTo={returnTo}
/>
);
}

View File

@ -2,14 +2,11 @@ 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';
@ -32,13 +29,11 @@ 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,
};
@ -49,19 +44,15 @@ export default function Layout() {
}
export function ErrorBoundary({ loaderData }: Route.ErrorBoundaryProps) {
const { isGoogleSSOEnabled, isMicrosoftSSOEnabled, isOIDCSSOEnabled, oidcProviderLabel } =
loaderData || {};
const { isGoogleSSOEnabled, 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}
@ -77,16 +68,6 @@ 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

@ -1,332 +0,0 @@
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

@ -0,0 +1,138 @@
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}
envelopeItems={template.envelopeItems}
recipient={recipient}
fields={fields}
metadata={template.templateMeta}
hidePoweredBy={hidePoweredBy}
allowWhiteLabelling={allowEmbedSigningWhitelabel}
/>
</DocumentSigningRecipientProvider>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}

View File

@ -1,394 +0,0 @@
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

@ -0,0 +1,181 @@
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}
envelopeItems={document.envelopeItems}
recipient={recipient}
fields={fields}
completedFields={completedFields}
metadata={document.documentMeta}
isCompleted={isDocumentCompleted(document.status)}
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
allRecipients={allRecipients}
/>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}

View File

@ -67,7 +67,6 @@ 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

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

View File

@ -41,7 +41,6 @@
"@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",
@ -88,7 +87,6 @@
"@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",
@ -106,5 +104,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.0.6"
"version": "1.13.1"
}

View File

@ -1,192 +0,0 @@
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

@ -1,29 +0,0 @@
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,11 +1,10 @@
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';
import type { HonoEnv } from '../router';
type HandleEnvelopeItemFileRequestOptions = {
title: string;
@ -35,7 +34,7 @@ export const handleEnvelopeItemFileRequest = async ({
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
if (c.req.header('If-None-Match') === etag && !isDownload) {
if (c.req.header('If-None-Match') === etag) {
return c.body(null, 304);
}
@ -53,13 +52,15 @@ export const handleEnvelopeItemFileRequest = async ({
}
c.header('Content-Type', 'application/pdf');
c.header('Content-Length', file.length.toString());
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');
// Set a tiny 1 minute cache, with must-revalidate to ensure the client always checks for updates.
c.header('Cache-Control', 'public, max-age=60, must-revalidate');
}
}
@ -69,7 +70,7 @@ export const handleEnvelopeItemFileRequest = async ({
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
c.header('Content-Disposition', contentDisposition(filename));
c.header('Content-Disposition', `attachment; filename="${filename}"`);
// For downloads, prevent caching to ensure fresh data
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');

View File

@ -10,7 +10,7 @@ import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
import type { HonoEnv } from '../router';
import { handleEnvelopeItemFileRequest } from './files.helpers';
import {
type TGetPresignedPostUrlResponse,

View File

@ -14,8 +14,7 @@ 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 { downloadRoute } from './api/download/download';
import { filesRoute } from './api/files/files';
import { filesRoute } from './api/files';
import { type AppContext, appContext } from './context';
import { appMiddleware } from './middleware';
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
@ -93,8 +92,6 @@ 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,
@ -104,8 +101,6 @@ app.use(`${API_V2_URL}/*`, async (c) =>
// 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());
// 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,

Binary file not shown.

15
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.0.6",
"version": "1.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.0.6",
"version": "1.13.1",
"workspaces": [
"apps/*",
"packages/*"
@ -100,7 +100,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.0.6",
"version": "1.13.1",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",
@ -129,7 +129,6 @@
"@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",
@ -176,7 +175,6 @@
"@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",
@ -12317,13 +12315,6 @@
"@types/node": "*"
}
},
"node_modules/@types/content-disposition": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
"integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cross-spawn": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@types/cross-spawn/-/cross-spawn-6.0.2.tgz",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "2.0.6",
"version": "1.13.1",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@ -95,4 +95,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

View File

@ -1,6 +1,4 @@
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';
@ -15,66 +13,11 @@ export type FieldTestData = TFieldAndMeta & {
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),
};
};
const alignmentGridStartX = 31;
const alignmentGridStartY = 19.02;
export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
/**
@ -88,7 +31,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'email',
},
page: 1,
...calculatePositionPageOne(0, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
{
@ -98,7 +44,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'email',
},
page: 1,
...calculatePositionPageOne(0, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
{
@ -109,7 +58,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'email',
},
page: 1,
...calculatePositionPageOne(0, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'admin@documenso.com',
},
/**
@ -123,7 +75,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'name',
},
page: 1,
...calculatePositionPageOne(1, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
{
@ -133,7 +88,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'name',
},
page: 1,
...calculatePositionPageOne(1, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
{
@ -144,7 +102,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'name',
},
page: 1,
...calculatePositionPageOne(1, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'John Doe',
},
/**
@ -158,7 +119,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'date',
},
page: 1,
...calculatePositionPageOne(2, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
@ -168,7 +132,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'date',
},
page: 1,
...calculatePositionPageOne(2, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
@ -179,7 +146,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'date',
},
page: 1,
...calculatePositionPageOne(2, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
@ -193,7 +163,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'text',
},
page: 1,
...calculatePositionPageOne(3, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
@ -203,7 +176,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'text',
},
page: 1,
...calculatePositionPageOne(3, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
@ -214,7 +190,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'text',
},
page: 1,
...calculatePositionPageOne(3, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
@ -228,7 +207,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'number',
},
page: 1,
...calculatePositionPageOne(4, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
@ -238,7 +220,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'number',
},
page: 1,
...calculatePositionPageOne(4, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
{
@ -249,7 +234,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'number',
},
page: 1,
...calculatePositionPageOne(4, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '123456789',
},
/**
@ -263,7 +251,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'initials',
},
page: 1,
...calculatePositionPageOne(5, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
{
@ -273,7 +264,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'initials',
},
page: 1,
...calculatePositionPageOne(5, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
{
@ -284,7 +278,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'initials',
},
page: 1,
...calculatePositionPageOne(5, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'JD',
},
/**
@ -302,7 +299,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
],
},
page: 1,
...calculatePositionPageOne(6, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '0',
},
{
@ -312,12 +312,15 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(6, 1),
customText: '',
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '2',
},
{
type: FieldType.RADIO,
@ -327,12 +330,15 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(6, 2),
customText: '1',
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
},
/**
* Row 8 Checkbox
@ -349,7 +355,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
],
},
page: 1,
...calculatePositionPageOne(7, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: toCheckboxCustomText([0]),
},
{
@ -359,12 +368,15 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 2, checked: true, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(7, 1),
customText: '',
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: toCheckboxCustomText([1]),
},
{
type: FieldType.CHECKBOX,
@ -374,12 +386,15 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'checkbox',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 1,
...calculatePositionPageOne(7, 2),
customText: toCheckboxCustomText([1]),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
},
/**
* Row 8 Dropdown
@ -392,7 +407,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'dropdown',
},
page: 1,
...calculatePositionPageOne(8, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
{
@ -402,7 +420,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'dropdown',
},
page: 1,
...calculatePositionPageOne(8, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
{
@ -413,7 +434,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'dropdown',
},
page: 1,
...calculatePositionPageOne(8, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: 'Option 1',
},
/**
@ -426,7 +450,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'signature',
},
page: 1,
...calculatePositionPageOne(9, 0),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
@ -436,7 +463,10 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'signature',
},
page: 1,
...calculatePositionPageOne(9, 1),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
customText: '',
signature: 'My Signature',
},
@ -447,295 +477,22 @@ export const ALIGNMENT_TEST_FIELDS: FieldTestData[] = [
type: 'signature',
},
page: 1,
...calculatePositionPageOne(9, 2),
height: rowHeight,
width: columnWidth,
positionX: 0,
positionY: 0,
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;
export const formatAlignmentTestFields = ALIGNMENT_TEST_FIELDS.map((field, index) => {
const row = Math.floor(index / 3);
const column = index % 3;
return {
...field,
positionX: alignmentGridStartX + column * columnWidth,
positionY: alignmentGridStartY + row * rowHeight,
};
});

View File

@ -7,7 +7,6 @@ import {
} 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;
@ -38,7 +37,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
page: 2,
...calculatePosition(0, 0),
customText: '',
signature: signatureBase64Demo,
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
@ -48,7 +47,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
page: 2,
...calculatePosition(1, 0),
customText: '',
signature: signatureBase64Demo,
signature: 'My Signature',
},
{
type: FieldType.SIGNATURE,
@ -68,7 +67,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
page: 2,
...calculatePosition(3, 0),
customText: '',
signature: 'My Signature super overflow maybe',
signature: 'My Signature',
},
/**
@ -81,7 +80,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(0, 0, 'full'),
customText: 'Hello world, this is some random text that I have written here',
customText: '123456789',
},
{
type: FieldType.TEXT,
@ -90,7 +89,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(1, 0),
customText: 'Some text that should overflow correctly',
customText: '123456789123456789123456789123456789',
},
{
type: FieldType.TEXT,
@ -110,7 +109,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(3, 0),
customText: 'Input should have a placeholder text when clicked',
customText: '123456789',
},
{
type: FieldType.TEXT,
@ -120,7 +119,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(3, 1),
customText: 'Should have a label during editing and signing',
customText: '123456789',
},
{
type: FieldType.TEXT,
@ -130,7 +129,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(3, 2),
customText: '',
customText: '123456789',
},
{
type: FieldType.TEXT,
@ -140,19 +139,20 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 3,
...calculatePosition(4, 0),
customText: 'This is a required field',
customText: '123456789',
},
{
type: FieldType.TEXT,
fieldMeta: {
type: 'text',
readOnly: true,
text: 'Some Readonly Value',
text: 'Readonly Value',
},
page: 3,
...calculatePosition(4, 1),
customText: '',
customText: 'Readonly Value',
},
/**
* PAGE 4 NUMBER
*/
@ -220,7 +220,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
type: FieldType.NUMBER,
fieldMeta: {
type: 'number',
value: '123456789',
value: '123',
},
page: 4,
...calculatePosition(3, 2),
@ -241,11 +241,10 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
type: 'number',
readOnly: true,
value: '123456789',
},
page: 4,
...calculatePosition(4, 1),
customText: '',
customText: '123456789',
},
/**
@ -273,8 +272,8 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
type: 'radio',
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
{ id: 3, checked: true, value: 'Option 3' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
@ -286,7 +285,6 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
direction: 'vertical',
type: 'radio',
required: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: false, value: 'Option 2' },
@ -295,18 +293,17 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 5,
...calculatePosition(2, 0),
customText: '2',
customText: '',
},
{
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' },
{ id: 3, checked: false, value: 'Option 3' },
],
},
page: 5,
@ -341,7 +338,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 3, checked: false, value: 'Option 3' },
{ id: 2, checked: true, value: 'Option 3' },
],
},
page: 6,
@ -361,7 +358,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 6,
...calculatePosition(2, 0),
customText: toCheckboxCustomText([2]),
customText: '',
},
{
type: FieldType.CHECKBOX,
@ -371,7 +368,7 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
readOnly: true,
values: [
{ id: 1, checked: false, value: 'Option 1' },
{ id: 2, checked: true, value: 'Option 2' },
{ id: 2, checked: false, value: 'Option 2' },
],
},
page: 6,
@ -448,11 +445,11 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }],
type: 'dropdown',
defaultValue: 'Option 2',
defaultValue: 'Option 1',
},
page: 7,
...calculatePosition(1, 0),
customText: 'Option 2',
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
@ -463,14 +460,13 @@ export const FIELD_META_TEST_FIELDS: FieldTestData[] = [
},
page: 7,
...calculatePosition(2, 0),
customText: 'Option 3',
customText: 'Option 1',
},
{
type: FieldType.DROPDOWN,
fieldMeta: {
values: [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }],
type: 'dropdown',
defaultValue: 'Option 1',
readOnly: true,
},
page: 7,

View File

@ -18,7 +18,7 @@ import {
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 { TCreateEnvelopeItemsRequest } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
@ -27,7 +27,7 @@ import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/en
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 { formatAlignmentTestFields } from '../../../constants/field-alignment-pdf';
import { FIELD_META_TEST_FIELDS } from '../../../constants/field-meta-pdf';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@ -403,20 +403,28 @@ test.describe('API V2 Envelopes', () => {
expect(unauthRequest.status()).toBe(404);
// Step 2: Create second envelope item via API
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
envelopeId: createdEnvelope.id,
};
// Todo: Envelopes - Use API Route
const fieldMetaDocumentData = await prisma.documentData.create({
data: {
type: 'BYTES_64',
data: fieldMetaPdf.toString('base64'),
initialData: fieldMetaPdf.toString('base64'),
},
});
const createEnvelopeItemFormData = new FormData();
createEnvelopeItemFormData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
createEnvelopeItemFormData.append(
'files',
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
);
const createEnvelopeItemsRequest: TCreateEnvelopeItemsRequest = {
envelopeId: createdEnvelope.id,
data: [
{
title: 'Field Meta Test',
documentDataId: fieldMetaDocumentData.id,
},
],
};
const createItemsRes = await request.post(`${baseUrl}/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: createEnvelopeItemFormData,
data: createEnvelopeItemsRequest,
});
expect(createItemsRes.ok()).toBeTruthy();
@ -490,7 +498,7 @@ test.describe('API V2 Envelopes', () => {
// Step 6: Create fields for first PDF (alignment fields)
const alignmentFieldsRequest = {
envelopeId: createdEnvelope.id,
data: ALIGNMENT_TEST_FIELDS.map((field) => ({
data: formatAlignmentTestFields.map((field) => ({
recipientId,
envelopeItemId: alignmentItem.id,
type: field.type,
@ -547,7 +555,7 @@ test.describe('API V2 Envelopes', () => {
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,
formatAlignmentTestFields.length + FIELD_META_TEST_FIELDS.length,
);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);

View File

@ -30,7 +30,6 @@ import {
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import type { TCreateEnvelopeItemsPayload } from '@documenso/trpc/server/envelope-router/create-envelope-items.types';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
@ -3856,24 +3855,25 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
envelopeId: doc.id,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
formData.append(
'files',
new File([fieldMetaPdf], 'field-meta.pdf', { type: 'application/pdf' }),
);
const documentData = await prisma.documentData.create({
data: {
type: 'BYTES_64',
data: Buffer.from('test pdf content').toString('base64'),
initialData: Buffer.from('test pdf content').toString('base64'),
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${tokenB}` },
multipart: formData,
data: {
envelopeId: doc.id,
data: [
{
title: 'New Item',
documentDataId: documentData.id,
},
],
},
});
expect(res.ok()).toBeFalsy();
@ -3885,48 +3885,29 @@ test.describe('Document API V2', () => {
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const fieldMetaPdf = fs.readFileSync(
path.join(__dirname, '../../../../../assets/field-meta.pdf'),
);
const createEnvelopeItemsPayload: TCreateEnvelopeItemsPayload = {
envelopeId: doc.id,
};
const formData = new FormData();
formData.append('payload', JSON.stringify(createEnvelopeItemsPayload));
formData.append(
'files',
new File([fieldMetaPdf], 'field-meta-1.pdf', { type: 'application/pdf' }),
);
formData.append(
'files',
new File([fieldMetaPdf], 'field-meta-2.pdf', { type: 'application/pdf' }),
);
const documentData = await prisma.documentData.create({
data: {
type: 'BYTES_64',
data: Buffer.from('test pdf content').toString('base64'),
initialData: Buffer.from('test pdf content').toString('base64'),
},
});
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/item/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: doc.id,
},
include: {
envelopeItems: true,
data: {
envelopeId: doc.id,
data: [
{
title: 'New Item',
documentDataId: documentData.id,
},
],
},
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const envelopeItems = envelope.envelopeItems;
// 3 Files because seed creates one automatically.
expect(envelopeItems.length).toBe(3);
expect(envelopeItems[1].title).toBe('field-meta-1.pdf');
expect(envelopeItems[2].title).toBe('field-meta-2.pdf');
});
});

View File

@ -21,226 +21,34 @@ import pixelMatch from 'pixelmatch';
import { PNG } from 'pngjs';
import type { TestInfo } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { DocumentStatus } from '@prisma/client';
import fs from 'node:fs';
import path from 'node:path';
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.js';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { getEnvelopeDownloadUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '../../../trpc/server/envelope-router/create-envelope.types';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../../lib/constants/app';
import { createApiToken } from '../../../lib/server-only/public-api/create-api-token';
import { RecipientRole } from '../../../prisma/generated/types';
import { FIELD_META_TEST_FIELDS } from '../../constants/field-meta-pdf';
import { ALIGNMENT_TEST_FIELDS } from '../../constants/field-alignment-pdf';
import type { TDistributeEnvelopeRequest } from '../../../trpc/server/envelope-router/distribute-envelope.types';
import { isBase64Image } from '../../../lib/constants/signatures';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const baseUrl = `${WEBAPP_BASE_URL}/api/v2`;
import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
test.skip('seed alignment test document', async ({ page }) => {
const user = await prisma.user.findFirstOrThrow({
where: {
email: 'example@documenso.com',
},
include: {
ownedOrganisations: {
include: {
teams: true,
},
},
},
});
const userId = user.id;
const teamId = user.ownedOrganisations[0].teams[0].id;
await seedAlignmentTestDocument({
userId,
teamId,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: false,
status: DocumentStatus.DRAFT,
});
});
test('field placement visual regression', async ({ page, request }, testInfo) => {
test('field placement visual regression', async ({ page }, testInfo) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
const envelope = await seedAlignmentTestDocument({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
recipientName: user.name || '',
recipientEmail: user.email,
insertFields: true,
status: DocumentStatus.PENDING,
});
// Step 1: Create initial envelope with Prisma (with first envelope item)
const alignmentPdf = fs.readFileSync(
path.join(__dirname, '../../../../assets/field-font-alignment.pdf'),
);
const token = envelope.recipients[0].token;
const fieldMetaPdf = fs.readFileSync(path.join(__dirname, '../../../../assets/field-meta.pdf'));
const formData = new FormData();
const fieldMetaFields = FIELD_META_TEST_FIELDS.map((field) => ({
identifier: 'field-meta',
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
}));
const alignmentFields = ALIGNMENT_TEST_FIELDS.map((field) => ({
identifier: 'alignment-pdf',
type: field.type,
page: field.page,
positionX: field.positionX,
positionY: field.positionY,
width: field.width,
height: field.height,
fieldMeta: field.fieldMeta,
}));
const createEnvelopePayload: TCreateEnvelopePayload = {
type: EnvelopeType.DOCUMENT,
title: 'Envelope Full Field Test',
recipients: [
{
email: user.email,
name: user.name || '',
role: RecipientRole.SIGNER,
fields: [...fieldMetaFields, ...alignmentFields],
},
],
};
formData.append('payload', JSON.stringify(createEnvelopePayload));
formData.append('files', new File([alignmentPdf], 'alignment-pdf', { type: 'application/pdf' }));
formData.append('files', new File([fieldMetaPdf], 'field-meta', { type: 'application/pdf' }));
const createEnvelopeRequest = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${token}` },
multipart: formData,
});
expect(createEnvelopeRequest.ok()).toBeTruthy();
expect(createEnvelopeRequest.status()).toBe(200);
const { id: createdEnvelopeId }: TCreateEnvelopeResponse = await createEnvelopeRequest.json();
const envelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: createdEnvelopeId,
},
include: {
recipients: true,
envelopeItems: true,
},
});
const recipientId = envelope.recipients[0].id;
const alignmentItem = envelope.envelopeItems.find((item: { order: number }) => item.order === 1);
const fieldMetaItem = envelope.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');
}
const distributeEnvelopeRequest = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${token}` },
data: {
envelopeId: envelope.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeEnvelopeRequest.ok()).toBeTruthy();
const uninsertedFields = await prisma.field.findMany({
where: {
envelopeId: envelope.id,
inserted: false,
},
include: {
envelopeItem: {
select: {
title: true,
},
},
},
});
await Promise.all(
uninsertedFields.map(async (field) => {
let foundField = ALIGNMENT_TEST_FIELDS.find(
(f) =>
field.page === f.page &&
field.envelopeItem.title === 'alignment-pdf' &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
Number(field.height).toFixed(2) === f.height.toFixed(2),
);
if (!foundField) {
foundField = FIELD_META_TEST_FIELDS.find(
(f) =>
field.page === f.page &&
field.envelopeItem.title === 'field-meta' &&
Number(field.positionX).toFixed(2) === f.positionX.toFixed(2) &&
Number(field.positionY).toFixed(2) === f.positionY.toFixed(2) &&
Number(field.width).toFixed(2) === f.width.toFixed(2) &&
Number(field.height).toFixed(2) === f.height.toFixed(2),
);
}
if (!foundField) {
throw new Error('Field not found');
}
await prisma.field.update({
where: {
id: field.id,
},
data: {
inserted: true,
customText: foundField.customText,
signature: foundField.signature
? {
create: {
recipientId: envelope.recipients[0].id,
signatureImageAsBase64: isBase64Image(foundField.signature)
? foundField.signature
: null,
typedSignature: isBase64Image(foundField.signature) ? null : foundField.signature,
},
}
: undefined,
},
});
}),
);
const recipientToken = envelope.recipients[0].token;
const signUrl = `/sign/${recipientToken}`;
const signUrl = `/sign/${token}`;
await apiSignin({
page,
@ -286,10 +94,9 @@ test('field placement visual regression', async ({ page, request }, testInfo) =>
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: item,
token: recipientToken,
token,
version: 'signed',
});
@ -372,8 +179,7 @@ test.skip('download envelope images', async ({ page }) => {
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: item,
token,
version: 'signed',
@ -481,7 +287,7 @@ const compareSignedPdfWithImages = async ({
// Expect the certificate to NOT be blank. Since the storedImage is blank.
expect.soft(comparison).toBeGreaterThan(20000);
} else {
expect.soft(comparison).toBeLessThan(2);
expect.soft(comparison).toEqual(0);
}
}
};

View File

@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { getDocumentByToken } from '@documenso/lib/server-only/document/get-document-by-token';
import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download';
import { getEnvelopeDownloadUrl } from '@documenso/lib/utils/envelope-download';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams';
@ -34,8 +34,7 @@ test.describe('Signing Certificate Tests', () => {
},
})
.then(async (data) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: data,
token: recipient.token,
version: 'signed',
@ -86,8 +85,7 @@ test.describe('Signing Certificate Tests', () => {
const firstDocumentData = completedDocument.envelopeItems[0];
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: firstDocumentData,
token: recipient.token,
version: 'signed',
@ -141,8 +139,7 @@ test.describe('Signing Certificate Tests', () => {
},
})
.then(async (data) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: data,
token: recipient.token,
version: 'signed',
@ -191,8 +188,7 @@ test.describe('Signing Certificate Tests', () => {
const firstDocumentData = completedDocument.envelopeItems[0];
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: firstDocumentData,
token: recipient.token,
version: 'signed',
@ -246,8 +242,7 @@ test.describe('Signing Certificate Tests', () => {
},
})
.then(async (data) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: data,
token: recipient.token,
version: 'signed',
@ -294,8 +289,7 @@ test.describe('Signing Certificate Tests', () => {
},
});
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
const documentUrl = getEnvelopeDownloadUrl({
envelopeItem: completedDocument.envelopeItems[0],
token: recipient.token,
version: 'signed',

View File

@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { DocumentDataType, TeamMemberRole } from '@prisma/client';
import fs from 'fs';
import path from 'path';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@ -11,10 +12,6 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
const EXAMPLE_PDF_PATH = path.join(__dirname, '../../../../assets/example.pdf');
const FIELD_ALIGNMENT_TEST_PDF_PATH = path.join(
__dirname,
'../../../../assets/field-font-alignment.pdf',
);
/**
* 1. Create a template with all settings filled out
@ -236,6 +233,10 @@ test('[TEMPLATE]: should create a document from a template with custom document'
const { user, team } = await seedUser();
const template = await seedBlankTemplate(user, team.id);
// Create a temporary PDF file for upload
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
await apiSignin({
page,
email: user.email,
@ -276,7 +277,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
}),
]);
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
// Wait for upload to complete
await expect(page.getByText('Remove')).toBeVisible();
@ -313,12 +314,8 @@ test('[TEMPLATE]: should create a document from a template with custom document'
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
// Probably need to do a pixel match
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).not.toEqual(
template.envelopeItems[0].documentData.initialData,
);
expect(firstDocumentData.data).toEqual(pdfContent);
expect(firstDocumentData.initialData).toEqual(pdfContent);
} else {
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
expect(firstDocumentData.data).toBeTruthy();
@ -339,6 +336,8 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
const template = await seedBlankTemplate(owner, team.id);
const pdfContent = fs.readFileSync(EXAMPLE_PDF_PATH).toString('base64');
await apiSignin({
page,
email: owner.email,
@ -379,7 +378,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
}),
]);
await fileChooser.setFiles(FIELD_ALIGNMENT_TEST_PDF_PATH);
await fileChooser.setFiles(EXAMPLE_PDF_PATH);
// Wait for upload to complete
await expect(page.getByText('Remove')).toBeVisible();
@ -417,12 +416,8 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
expect(firstDocumentData.type).toEqual(expectedDocumentDataType);
if (expectedDocumentDataType === DocumentDataType.BYTES_64) {
// Todo: Doesn't really work due to normalization of the PDF which won't let us directly compare the data.
// Probably need to do a pixel match
expect(firstDocumentData.data).not.toEqual(template.envelopeItems[0].documentData.data);
expect(firstDocumentData.initialData).not.toEqual(
template.envelopeItems[0].documentData.initialData,
);
expect(firstDocumentData.data).toEqual(pdfContent);
expect(firstDocumentData.initialData).toEqual(pdfContent);
} else {
// For S3, we expect the data/initialData to be the S3 path (non-empty string)
expect(firstDocumentData.data).toBeTruthy();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 117 KiB

View File

@ -5,7 +5,6 @@ import { deleteCookie } from 'hono/cookie';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
import { isValidReturnTo, normalizeReturnTo } from '@documenso/lib/utils/is-valid-return-to';
import { prisma } from '@documenso/prisma';
import type { OAuthClientOptions } from '../../config';
@ -178,12 +177,6 @@ export const validateOauth = async (options: HandleOAuthCallbackUrlOptions) => {
redirectPath = '/';
}
if (!isValidReturnTo(redirectPath)) {
redirectPath = '/';
}
redirectPath = normalizeReturnTo(redirectPath) || '/';
const tokens = await oAuthClient.validateAuthorizationCode(
token_endpoint,
code,

View File

@ -11,7 +11,7 @@ export const validateNumberField = (
const { minValue, maxValue, readOnly, required, numberFormat, fontSize } = fieldMeta || {};
if (numberFormat && value.length > 0) {
if (numberFormat) {
const foundRegex = numberFormatValues.find((item) => item.value === numberFormat)?.regex;
if (!foundRegex) {

View File

@ -1,6 +1,6 @@
import type { EnvelopeItem } from '@prisma/client';
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
import { getEnvelopeDownloadUrl } from '../utils/envelope-download';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
@ -24,8 +24,7 @@ export const downloadPDF = async ({
fileName,
version = 'signed',
}: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
const downloadUrl = getEnvelopeDownloadUrl({
envelopeItem: envelopeItem,
token,
version,

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { Field, Recipient } from '@prisma/client';
import type { Recipient } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
@ -63,8 +63,6 @@ type UseEditorFieldsResponse = {
// Selected recipient
selectedRecipient: Recipient | null;
setSelectedRecipient: (recipientId: number | null) => void;
resetForm: (fields?: Field[]) => void;
};
export const useEditorFields = ({
@ -74,30 +72,24 @@ export const useEditorFields = ({
const [selectedFieldFormId, setSelectedFieldFormId] = useState<string | null>(null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const generateDefaultValues = (fields?: Field[]) => {
const formFields = (fields || envelope.fields).map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
);
return {
fields: formFields,
};
};
const form = useForm<TEditorFieldsFormSchema>({
defaultValues: generateDefaultValues(),
defaultValues: {
fields: envelope.fields.map(
(field): TLocalField => ({
id: field.id,
formId: nanoid(),
envelopeItemId: field.envelopeItemId,
page: field.page,
type: field.type,
positionX: Number(field.positionX),
positionY: Number(field.positionY),
width: Number(field.width),
height: Number(field.height),
recipientId: field.recipientId,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined,
}),
),
},
resolver: zodResolver(ZEditorFieldsFormSchema),
});
@ -280,10 +272,6 @@ export const useEditorFields = ({
setSelectedRecipientId(foundRecipient?.id ?? null);
};
const resetForm = (fields?: Field[]) => {
form.reset(generateDefaultValues(fields));
};
return {
// Core state
localFields,
@ -307,8 +295,6 @@ export const useEditorFields = ({
// Selected recipient
selectedRecipient,
setSelectedRecipient,
resetForm,
};
};

View File

@ -107,10 +107,6 @@ export function usePageRenderer(renderFunction: RenderFunction) {
stage: stage.current,
pageLayer: pageLayer.current,
});
void document.fonts.ready.then(function () {
pageLayer.current?.batchDraw();
});
});
return () => {

View File

@ -150,7 +150,7 @@ export const EnvelopeEditorProvider = ({
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ data: recipients }) => {
onSuccess: ({ recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
@ -196,7 +196,7 @@ export const EnvelopeEditorProvider = ({
});
// Insert the IDs into the local fields.
envelopeFields.data.forEach((field) => {
envelopeFields.fields.forEach((field) => {
const localField = localFields.find((localField) => localField.formId === field.formId);
if (localField && !localField.id) {

View File

@ -1,14 +1,11 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { Field, Recipient } from '@prisma/client';
import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors';
import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors';
import type { TEnvelope } from '../../types/envelope';
import type { FieldRenderMode } from '../../universal/field-renderer/render-field';
import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download';
import { getEnvelopeDownloadUrl } from '../../utils/envelope-download';
type FileData =
| {
@ -20,22 +17,17 @@ type FileData =
};
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
showRecipientTooltip?: boolean;
showRecipientSigningStatus?: boolean;
mode: 'edit' | 'sign' | 'export';
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null;
getPdfBuffer: (documentDataId: string) => FileData | null;
envelopeItems: EnvelopeRenderItem[];
envelopeStatus: TEnvelope['status'];
envelopeType: TEnvelope['type'];
currentEnvelopeItem: EnvelopeRenderItem | null;
setCurrentEnvelopeItem: (envelopeItemId: string) => void;
fields: Field[];
recipients: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
fields: TEnvelope['fields'];
getRecipientColorKey: (recipientId: number) => TRecipientColor;
renderError: boolean;
@ -46,22 +38,21 @@ type EnvelopeRenderProviderValue = {
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems' | 'status' | 'type'>;
envelope: Pick<TEnvelope, 'envelopeItems'>;
/**
* Optional fields which are passed down to renderers for custom rendering needs.
*
* Only pass if the CustomRenderer you are passing in wants fields.
*/
fields?: Field[];
fields?: TEnvelope['fields'];
/**
* Optional recipient used to determine the color of the fields and hover
* previews.
* Optional recipient IDs used to determine the color of the fields.
*
* Only required for generic page renderers.
*/
recipients?: Pick<Recipient, 'id' | 'name' | 'email' | 'signingStatus'>[];
recipientIds?: number[];
/**
* The token to access the envelope.
@ -96,13 +87,13 @@ export const EnvelopeRenderProvider = ({
envelope,
fields,
token,
recipients = [],
recipientIds = [],
overrideSettings,
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
const [currentItem, setCurrentItem] = useState<EnvelopeRenderItem | null>(null);
const [currentItem, setItem] = useState<EnvelopeRenderItem | null>(null);
const [renderError, setRenderError] = useState<boolean>(false);
@ -112,24 +103,24 @@ export const EnvelopeRenderProvider = ({
);
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
if (files[envelopeItem.id]?.status === 'loading') {
if (files[envelopeItem.documentDataId]?.status === 'loading') {
return;
}
if (!files[envelopeItem.id]) {
if (!files[envelopeItem.documentDataId]) {
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
[envelopeItem.documentDataId]: {
status: 'loading',
},
}));
}
try {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'view',
const downloadUrl = getEnvelopeDownloadUrl({
envelopeItem: envelopeItem,
token,
version: 'signed',
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
@ -138,7 +129,7 @@ export const EnvelopeRenderProvider = ({
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
[envelopeItem.documentDataId]: {
file: new Uint8Array(file),
status: 'loaded',
},
@ -148,7 +139,7 @@ export const EnvelopeRenderProvider = ({
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
[envelopeItem.documentDataId]: {
status: 'error',
},
}));
@ -156,8 +147,8 @@ export const EnvelopeRenderProvider = ({
};
const getPdfBuffer = useCallback(
(envelopeItemId: string) => {
return files[envelopeItemId] || null;
(documentDataId: string) => {
return files[documentDataId] || null;
},
[files],
);
@ -165,15 +156,11 @@ export const EnvelopeRenderProvider = ({
const setCurrentEnvelopeItem = (envelopeItemId: string) => {
const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId);
setCurrentItem(foundItem ?? null);
setItem(foundItem ?? null);
};
// Set the selected item to the first item if none is set.
useEffect(() => {
if (currentItem && !envelopeItems.some((item) => item.id === currentItem.id)) {
setCurrentItem(null);
}
if (!currentItem && envelopeItems.length > 0) {
setCurrentEnvelopeItem(envelopeItems[0].id);
}
@ -181,18 +168,13 @@ export const EnvelopeRenderProvider = ({
// Look for any missing pdf files and load them.
useEffect(() => {
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]);
const missingFiles = envelope.envelopeItems.filter((item) => !files[item.documentDataId]);
for (const item of missingFiles) {
void loadEnvelopeItemPdfFile(item);
}
}, [envelope.envelopeItems]);
const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(),
[recipients],
);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
@ -209,12 +191,9 @@ export const EnvelopeRenderProvider = ({
value={{
getPdfBuffer,
envelopeItems,
envelopeStatus: envelope.status,
envelopeType: envelope.type,
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
recipients,
getRecipientColorKey,
renderError,
setRenderError,

View File

@ -32,7 +32,6 @@ export type JobDefinition<Name extends string = string, Schema = any> = {
name: string;
version: string;
enabled?: boolean;
optimizeParallelism?: boolean;
trigger: {
name: Name;
schema?: z.ZodType<Schema>;

View File

@ -40,7 +40,6 @@ export class InngestJobProvider extends BaseJobProvider {
{
id: job.id,
name: job.name,
optimizeParallelism: job.optimizeParallelism ?? false,
},
{
event: job.trigger.name,

View File

@ -189,65 +189,29 @@ export const run = async ({
settings,
});
// !: The commented out code is our desired implementation but we're seemingly
// !: running into issues with inngest parallelism in production.
// !: Until this is resolved we will do this sequentially which is slower but
// !: will actually work.
// const decoratePromises: Array<Promise<{ oldDocumentDataId: string; newDocumentDataId: string }>> =
// [];
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
// for (const envelopeItem of envelopeItems) {
// const task = io.runTask(`decorate-${envelopeItem.id}`, async () => {
// const envelopeItemFields = envelope.envelopeItems.find(
// (item) => item.id === envelopeItem.id,
// )?.field;
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
// if (!envelopeItemFields) {
// throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
// }
// return decorateAndSignPdf({
// envelope,
// envelopeItem,
// envelopeItemFields,
// isRejected,
// rejectionReason,
// certificateData,
// auditLogData,
// });
// });
// decoratePromises.push(task);
// }
// const newDocumentData = await Promise.all(decoratePromises);
// TODO: Remove once parallelization is working
const newDocumentData: Array<{ oldDocumentDataId: string; newDocumentDataId: string }> = [];
for (const envelopeItem of envelopeItems) {
const result = await io.runTask(`decorate-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;
if (!envelopeItemFields) {
throw new Error(`Envelope item fields not found for envelope item ${envelopeItem.id}`);
}
return decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
});
});
newDocumentData.push(result);
}
return decorateAndSignPdf({
envelope,
envelopeItem,
envelopeItemFields,
isRejected,
rejectionReason,
certificateData,
auditLogData,
});
}),
),
);
const postHog = PostHogServerClient();

View File

@ -18,7 +18,6 @@ export const SEAL_DOCUMENT_JOB_DEFINITION = {
id: SEAL_DOCUMENT_JOB_DEFINITION_ID,
name: 'Seal Document',
version: '1.0.0',
optimizeParallelism: true,
trigger: {
name: SEAL_DOCUMENT_JOB_DEFINITION_ID,
schema: SEAL_DOCUMENT_JOB_DEFINITION_SCHEMA,

View File

@ -91,11 +91,7 @@ export const getDocumentAndSenderByToken = async ({
},
},
envelopeItems: {
select: {
id: true,
title: true,
order: true,
envelopeId: true,
include: {
documentData: true,
},
},

View File

@ -1,4 +1,4 @@
import type { DocumentData, Envelope, EnvelopeItem, Field } from '@prisma/client';
import type { DocumentData, Envelope, EnvelopeItem } from '@prisma/client';
import {
DocumentSigningOrder,
DocumentStatus,
@ -24,9 +24,7 @@ import {
ZCheckboxFieldMeta,
ZDropdownFieldMeta,
ZFieldAndMetaSchema,
ZNumberFieldMeta,
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import {
ZWebhookDocumentSchema,
@ -184,19 +182,80 @@ export const sendDocument = async ({
// Validate and autoinsert fields for V2 envelopes.
if (envelope.internalVersion === 2) {
for (const unknownField of envelope.fields) {
const recipient = envelope.recipients.find((r) => r.id === unknownField.recipientId);
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
if (!recipient) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Recipient not found',
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
});
}
const fieldToAutoInsert = extractFieldAutoInsertValues(unknownField);
const field = parsedField.data;
const fieldId = unknownField.id;
// Only auto-insert fields if the recipient has not been sent the document yet.
if (fieldToAutoInsert && recipient.sendStatus !== SendStatus.SENT) {
fieldsToAutoInsert.push(fieldToAutoInsert);
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
fieldsToAutoInsert.push({
fieldId,
customText: toRadioCustomText(checkedItemIndex),
});
}
}
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
fieldsToAutoInsert.push({
fieldId,
customText: defaultValue,
});
}
}
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(
checkedIndices.length,
validation.value,
validationLength,
);
}
if (isValid && checkedIndices.length > 0) {
fieldsToAutoInsert.push({
fieldId,
customText: toCheckboxCustomText(checkedIndices),
});
}
}
}
}
@ -216,7 +275,6 @@ export const sendDocument = async ({
if (envelope.internalVersion === 2) {
const autoInsertedFields = await Promise.all(
fieldsToAutoInsert.map(async (field) => {
// Warning: Only auto-insert fields if the recipient has not been sent the document yet.
return await tx.field.update({
where: {
id: field.fieldId,
@ -329,113 +387,3 @@ const injectFormValuesIntoDocument = async (
},
});
};
/**
* Extracts the auto insertion values for a given field.
*
* If field is not auto insertable, returns `null`.
*/
export const extractFieldAutoInsertValues = (
unknownField: Field,
): { fieldId: number; customText: string } | null => {
const parsedField = ZFieldAndMetaSchema.safeParse(unknownField);
if (parsedField.error) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'One or more fields have invalid metadata. Error: ' + parsedField.error.message,
});
}
const field = parsedField.data;
const fieldId = unknownField.id;
// Auto insert text fields with prefilled values.
if (field.type === FieldType.TEXT) {
const { text } = ZTextFieldMeta.parse(field.fieldMeta);
if (text) {
return {
fieldId,
customText: text,
};
}
}
// Auto insert number fields with prefilled values.
if (field.type === FieldType.NUMBER) {
const { value } = ZNumberFieldMeta.parse(field.fieldMeta);
if (value) {
return {
fieldId,
customText: value,
};
}
}
// Auto insert radio fields with the pre-checked value.
if (field.type === FieldType.RADIO) {
const { values = [] } = ZRadioFieldMeta.parse(field.fieldMeta);
const checkedItemIndex = values.findIndex((value) => value.checked);
if (checkedItemIndex !== -1) {
return {
fieldId,
customText: toRadioCustomText(checkedItemIndex),
};
}
}
// Auto insert dropdown fields with the default value.
if (field.type === FieldType.DROPDOWN) {
const { defaultValue, values = [] } = ZDropdownFieldMeta.parse(field.fieldMeta);
if (defaultValue && values.some((value) => value.value === defaultValue)) {
return {
fieldId,
customText: defaultValue,
};
}
}
// Auto insert checkbox fields with the pre-checked values.
if (field.type === FieldType.CHECKBOX) {
const {
values = [],
validationRule,
validationLength,
} = ZCheckboxFieldMeta.parse(field.fieldMeta);
const checkedIndices: number[] = [];
values.forEach((value, i) => {
if (value.checked) {
checkedIndices.push(i);
}
});
let isValid = true;
if (validationRule && validationLength) {
const validation = checkboxValidationSigns.find((sign) => sign.label === validationRule);
if (!validation) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Invalid checkbox validation rule',
});
}
isValid = validateCheckboxLength(checkedIndices.length, validation.value, validationLength);
}
if (isValid && checkedIndices.length > 0) {
return {
fieldId,
customText: toCheckboxCustomText(checkedIndices),
};
}
}
return null;
};

View File

@ -6,7 +6,6 @@ import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { DocumentAccessAuth, type TDocumentAuthMethods } from '../../types/document-auth';
import { extractDocumentAuthMethods } from '../../utils/document-auth';
import { extractFieldAutoInsertValues } from '../document/send-document';
import { getTeamSettings } from '../team/get-team-settings';
import type { EnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
import { ZEnvelopeForSigningResponse } from './get-envelope-for-recipient-signing';
@ -144,20 +143,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
envelope,
recipient: {
...recipient,
directToken: envelope.directLink?.token || '',
fields: recipient.fields.map((field) => {
const autoInsertValue = extractFieldAutoInsertValues(field);
if (!autoInsertValue) {
return field;
}
return {
...field,
inserted: true,
customText: autoInsertValue.customText,
};
}),
token: envelope.directLink?.token || '',
},
recipientSignature: null,
isRecipientsTurn: true,

View File

@ -11,7 +11,7 @@ import UserSchema from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuthMethods } from '../../types/document-auth';
import { ZEnvelopeFieldSchema, ZFieldSchema } from '../../types/field';
import { ZFieldSchema } from '../../types/field';
import { ZRecipientLiteSchema } from '../../types/recipient';
import { isRecipientAuthorized } from '../document/is-recipient-authorized';
import { getTeamSettings } from '../team/get-team-settings';
@ -63,11 +63,9 @@ export const ZEnvelopeForSigningResponse = z.object({
rejectionReason: true,
})
.extend({
fields: ZEnvelopeFieldSchema.extend({
signature: SignatureSchema.pick({
signatureImageAsBase64: true,
typedSignature: true,
}).nullish(),
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,
}).array(),
})
.array(),
@ -76,6 +74,7 @@ export const ZEnvelopeForSigningResponse = z.object({
envelopeId: true,
id: true,
title: true,
documentDataId: true,
order: true,
}).array(),
@ -109,7 +108,6 @@ export const ZEnvelopeForSigningResponse = z.object({
signingOrder: true,
rejectionReason: true,
}).extend({
directToken: z.string().nullish(),
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,

View File

@ -129,7 +129,7 @@ export const setFieldsForTemplate = async ({
if (field.type === FieldType.NUMBER && field.fieldMeta) {
const numberFieldParsedMeta = ZNumberFieldMeta.parse(field.fieldMeta);
const errors = validateNumberField(
String(numberFieldParsedMeta.value || ''),
String(numberFieldParsedMeta.value),
numberFieldParsedMeta,
);
if (errors.length > 0) {

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