Compare commits

..

17 Commits

Author SHA1 Message Date
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
187 changed files with 5387 additions and 8998 deletions

View File

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

View File

@ -2,10 +2,11 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@ -19,7 +20,9 @@ import {
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'title' | 'order'> & {
documentData: DocumentData;
};
type EnvelopeDownloadDialogProps = {
envelopeId: string;
@ -61,12 +64,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,
@ -84,11 +87,19 @@ export const EnvelopeDownloadDialog = ({
}));
try {
await downloadPDF({
envelopeItem,
token,
fileName: envelopeItem.title,
version,
const downloadUrl = token
? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${envelopeItemId}/download/${version}`
: `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${envelopeItemId}/download/${version}`;
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = envelopeItem.title.replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
const filename = `${baseTitle}${suffix}`;
downloadFile({
filename,
data: blob,
});
setIsDownloadingState((prev) => ({
@ -129,7 +140,7 @@ export const EnvelopeDownloadDialog = ({
<div className="flex flex-col gap-4">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 1 }).map((_, index) => (
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
@ -158,7 +169,6 @@ export const EnvelopeDownloadDialog = ({
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="text-foreground truncate text-sm font-medium">{item.title}</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>PDF Document</Trans>

View File

@ -43,7 +43,7 @@ export const EnvelopeDuplicateDialog = ({
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({
onSuccess: async ({ 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

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

View File

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

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

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

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

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

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

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

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

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

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

@ -245,12 +245,7 @@ export const DocumentSigningPageViewV1 = ({
<div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer
key={document.envelopeItems[0].id}
envelopeItem={document.envelopeItems[0]}
token={recipient.token}
version="signed"
/>
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
</CardContent>
</Card>
</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">
{/* Footer of left sidebar. */}
{!isEmbed && (
<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>
)}
{/* 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,8 +1,7 @@
import { useEffect, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { type DocumentData, DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon } from 'lucide-react';
import type { DocumentData, EnvelopeItem } from '@prisma/client';
import { DateTime } from 'luxon';
import {
@ -23,10 +22,9 @@ import {
} from '@documenso/ui/primitives/dialog';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector';
import EnvelopeGenericPageRenderer from '../envelope-editor/envelope-generic-page-renderer';
import { ShareDocumentDownloadButton } from '../share-document-download-button';
export type DocumentCertificateQRViewProps = {
documentId: number;
@ -36,7 +34,6 @@ export type DocumentCertificateQRViewProps = {
documentTeamUrl: string;
recipientCount?: number;
completedDate?: Date;
token: string;
};
export const DocumentCertificateQRView = ({
@ -47,7 +44,6 @@ export const DocumentCertificateQRView = ({
documentTeamUrl,
recipientCount = 0,
completedDate,
token,
}: DocumentCertificateQRViewProps) => {
const { data: documentViaUser } = trpc.document.get.useQuery({
documentId,
@ -100,12 +96,11 @@ export const DocumentCertificateQRView = ({
)}
{internalVersion === 2 ? (
<EnvelopeRenderProvider envelope={{ envelopeItems }} token={token}>
<EnvelopeRenderProvider envelope={{ envelopeItems }}>
<DocumentCertificateQrV2
title={title}
recipientCount={recipientCount}
formattedDate={formattedDate}
token={token}
/>
</EnvelopeRenderProvider>
) : (
@ -124,27 +119,14 @@ export const DocumentCertificateQRView = ({
</div>
</div>
<EnvelopeDownloadDialog
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="flex-1">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
<ShareDocumentDownloadButton
title={title}
documentData={envelopeItems[0].documentData}
/>
</div>
<div className="mt-12 w-full">
<PDFViewer
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={token}
version="signed"
/>
<PDFViewer key={envelopeItems[0].id} documentData={envelopeItems[0].documentData} />
</div>
</>
)}
@ -156,16 +138,14 @@ type DocumentCertificateQrV2Props = {
title: string;
recipientCount: number;
formattedDate: string;
token: string;
};
const DocumentCertificateQrV2 = ({
title,
recipientCount,
formattedDate,
token,
}: DocumentCertificateQrV2Props) => {
const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
return (
<div className="flex min-h-screen flex-col items-start">
@ -183,18 +163,12 @@ const DocumentCertificateQrV2 = ({
</div>
</div>
<EnvelopeDownloadDialog
envelopeId={envelopeItems[0].envelopeId}
envelopeStatus={DocumentStatus.COMPLETED}
envelopeItems={envelopeItems}
token={token}
trigger={
<Button type="button" variant="outline" className="flex-1">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
</Button>
}
/>
{currentEnvelopeItem && (
<ShareDocumentDownloadButton
title={title}
documentData={currentEnvelopeItem.documentData}
/>
)}
</div>
<div className="mt-12 w-full">

View File

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

View File

@ -1,14 +1,18 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
@ -19,6 +23,9 @@ export type DocumentPageViewButtonProps = {
export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const recipient = envelope.recipients.find((recipient) => recipient.email === user.email);
const isRecipient = !!recipient;
@ -30,6 +37,25 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
const documentsPath = formatDocumentsPath(envelope.team.url);
const formatPath = `${documentsPath}/${envelope.id}/edit`;
const onDownloadClick = async () => {
try {
// Todo; Envelopes - Support multiple items
const envelopeItem = envelope.envelopeItems[0];
if (!envelopeItem.documentData) {
throw new Error('No document available');
}
await downloadPDF({ documentData: envelopeItem.documentData, fileName: envelopeItem.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
return match({
isRecipient,
isPending,
@ -69,7 +95,7 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
</Link>
</Button>
))
.with({ isComplete: true }, () => (
.with({ isComplete: true, internalVersion: 2 }, () => (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
@ -83,5 +109,11 @@ export const DocumentPageViewButton = ({ envelope }: DocumentPageViewButtonProps
}
/>
))
.with({ isComplete: true }, () => (
<Button className="w-full" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
))
.otherwise(() => null);
};

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
@ -15,11 +16,13 @@ import {
} from 'lucide-react';
import { Link, useNavigate } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
DropdownMenu,
@ -64,6 +67,64 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
const documentsPath = formatDocumentsPath(team.url);
const onDownloadClick = async () => {
try {
const documentWithData = await trpcClient.document.get.query(
{
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
{
context: {
teamId: team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: envelope.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadOriginalClick = async () => {
try {
const documentWithData = await trpcClient.document.get.query(
{
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
},
{
context: {
teamId: team?.id?.toString(),
},
},
);
const documentData = documentWithData?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: envelope.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const nonSignedRecipients = envelope.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
@ -86,20 +147,36 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
</DropdownMenuItem>
)}
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
token={recipient?.token}
envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
{envelope.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
token={recipient?.token}
envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
}
/>
) : (
<>
{isComplete && (
<DropdownMenuItem onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={onDownloadOriginalClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans>
</DropdownMenuItem>
}
/>
</>
)}
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${envelope.id}/logs`}>
@ -173,8 +250,7 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
{isDuplicateDialogOpen && (
<DocumentDuplicateDialog
id={envelope.id}
token={recipient?.token}
id={mapSecondaryIdToDocumentId(envelope.secondaryId)}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@ -1,20 +1,10 @@
import { lazy, useEffect, useMemo, useState } from 'react';
import { lazy, useEffect, useState } from 'react';
import { faker } from '@faker-js/faker/locale/en';
import { Trans } from '@lingui/react/macro';
import { FieldType } from '@prisma/client';
import { FileTextIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { ConstructionIcon, FileTextIcon } from 'lucide-react';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import {
EnvelopeRenderProvider,
useCurrentEnvelopeRender,
} from '@documenso/lib/client-only/providers/envelope-render-provider';
import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta';
import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing';
import { toCheckboxCustomText } from '@documenso/lib/utils/fields';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
@ -25,169 +15,15 @@ import { EnvelopeRendererFileSelector } from './envelope-file-selector';
const EnvelopeGenericPageRenderer = lazy(async () => import('./envelope-generic-page-renderer'));
// Todo: Envelopes - Dynamically import faker
export const EnvelopeEditorPreviewPage = () => {
const { envelope, editorFields } = useCurrentEnvelopeEditor();
const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender();
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>(
'recipient',
);
const fieldsWithPlaceholders = useMemo(() => {
return fields.map((field) => {
const fieldMeta = ZFieldAndMetaSchema.parse(field);
const recipient = envelope.recipients.find((recipient) => recipient.id === field.recipientId);
if (!recipient) {
throw new Error('Recipient not found');
}
faker.seed(recipient.id);
const recipientName = recipient.name || faker.person.fullName();
const recipientEmail = recipient.email || faker.internet.email();
faker.seed(recipient.id + field.id);
return {
...field,
inserted: true,
...match(fieldMeta)
.with({ type: FieldType.TEXT }, ({ fieldMeta }) => {
let text = fieldMeta?.text || faker.lorem.words(5);
if (fieldMeta?.characterLimit) {
text = text.slice(0, fieldMeta?.characterLimit);
}
return {
customText: text,
};
})
.with({ type: FieldType.NUMBER }, ({ fieldMeta }) => {
let number = fieldMeta?.value ?? '';
if (number === '') {
number = faker.number
.int({
min: fieldMeta?.minValue ?? 0,
max: fieldMeta?.maxValue ?? 1000,
})
.toString();
}
return {
customText: number,
};
})
.with({ type: FieldType.DATE }, () => {
const date = extractFieldInsertionValues({
fieldValue: {
type: FieldType.DATE,
value: true,
},
field,
documentMeta: envelope.documentMeta,
});
return {
customText: date.customText,
};
})
.with({ type: FieldType.EMAIL }, () => {
return {
customText: recipientEmail,
};
})
.with({ type: FieldType.NAME }, () => {
return {
customText: recipientName,
};
})
.with({ type: FieldType.INITIALS }, () => {
return {
customText: extractInitials(recipientName),
};
})
.with({ type: FieldType.RADIO }, ({ fieldMeta }) => {
const values = fieldMeta?.values ?? [];
if (values.length === 0) {
return '';
}
let customText = '';
const preselectedValue = values.findIndex((value) => value.checked);
if (preselectedValue !== -1) {
customText = preselectedValue.toString();
} else {
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
customText = randomIndex.toString();
}
return {
customText,
};
})
.with({ type: FieldType.CHECKBOX }, ({ fieldMeta }) => {
let checkedValues: number[] = [];
const values = fieldMeta?.values ?? [];
values.forEach((value, index) => {
if (value.checked) {
checkedValues.push(index);
}
});
if (checkedValues.length === 0 && values.length > 0) {
const numberOfValues = fieldMeta?.validationLength || 1;
checkedValues = Array.from({ length: numberOfValues }, (_, index) => index);
}
return {
customText: toCheckboxCustomText(checkedValues),
};
})
.with({ type: FieldType.DROPDOWN }, ({ fieldMeta }) => {
const values = fieldMeta?.values ?? [];
let customText = fieldMeta?.defaultValue || '';
if (!customText && values.length > 0) {
const randomIndex = faker.number.int({ min: 0, max: values.length - 1 });
customText = values[randomIndex].value;
}
return {
customText,
};
})
.with({ type: FieldType.SIGNATURE }, () => {
return {
customText: '',
signature: {
signatureImageAsBase64: '',
typedSignature: recipientName,
},
};
})
.with({ type: FieldType.FREE_SIGNATURE }, () => {
return {
customText: '',
};
})
.exhaustive(),
};
});
}, [fields, envelope, envelope.recipients, envelope.documentMeta]);
/**
* Set the selected recipient to the first recipient in the envelope.
*/
@ -195,38 +31,40 @@ export const EnvelopeEditorPreviewPage = () => {
editorFields.setSelectedRecipient(envelope.recipients[0]?.id ?? null);
}, []);
// Override the parent renderer provider so we can inject custom fields.
return (
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={fieldsWithPlaceholders}
recipients={envelope.recipients}
overrideSettings={{
mode: 'export',
}}
>
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
{/* Horizontal envelope item selector */}
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* Document View */}
<div className="mt-4 flex flex-col items-center justify-center">
<Alert variant="warning" className="mb-4 max-w-[800px]">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* Coming soon section */}
<div className="border-border bg-card hover:bg-accent/10 flex w-full max-w-[800px] items-center gap-4 rounded-lg border p-4 transition-colors">
<div className="flex w-full flex-col items-center justify-center gap-2 py-32">
<ConstructionIcon className="text-muted-foreground h-10 w-10" />
<h3 className="text-foreground text-sm font-semibold">
<Trans>Coming soon</Trans>
</h3>
<p className="text-muted-foreground text-sm">
<Trans>This feature is coming soon</Trans>
</p>
</div>
</div>
{/* Todo: Envelopes - Remove div after preview mode is implemented */}
<div className="hidden">
{currentEnvelopeItem !== null ? (
<PDFViewerKonvaLazy
renderer="editor"
customPageRenderer={EnvelopeGenericPageRenderer}
/>
<PDFViewerKonvaLazy customPageRenderer={EnvelopeGenericPageRenderer} />
) : (
<div className="flex flex-col items-center justify-center py-32">
<FileTextIcon className="text-muted-foreground h-10 w-10" />
@ -240,28 +78,27 @@ export const EnvelopeEditorPreviewPage = () => {
)}
</div>
</div>
</div>
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
{/* Right Section - Form Fields Panel */}
{currentEnvelopeItem && false && (
<div className="sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l border-gray-200 bg-white py-4">
{/* Add fields section. */}
<section className="px-4">
{/* <h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Preivew Mode</Trans>
</h3> */}
<Alert variant="neutral">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
Preview what the signed document will look like with placeholder data
</Trans>
</AlertDescription>
</Alert>
<Alert variant="neutral">
<AlertTitle>
<Trans>Preview Mode</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview what the signed document will look like with placeholder data</Trans>
</AlertDescription>
</Alert>
{/* <Alert variant="neutral">
{/* <Alert variant="neutral">
<RadioGroup
className="gap-y-1"
value={selectedPreviewMode}
@ -300,37 +137,36 @@ export const EnvelopeEditorPreviewPage = () => {
<div>Preview what a recipient will see</div>
<div>Preview the signed document</div> */}
</section>
</section>
{false && (
<AnimateGenericFadeInOut key={selectedPreviewMode}>
{selectedPreviewMode === 'recipient' && (
<>
<Separator className="my-4" />
{false && (
<AnimateGenericFadeInOut key={selectedPreviewMode}>
{selectedPreviewMode === 'recipient' && (
<>
<Separator className="my-4" />
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Selected Recipient</Trans>
</h3>
{/* Recipient selector section. */}
<section className="px-4">
<h3 className="mb-2 text-sm font-semibold text-gray-900">
<Trans>Selected Recipient</Trans>
</h3>
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
</section>
</>
)}
</AnimateGenericFadeInOut>
)}
</div>
)}
</div>
</EnvelopeRenderProvider>
<RecipientSelector
selectedRecipient={editorFields.selectedRecipient}
onSelectedRecipientChange={(recipient) =>
editorFields.setSelectedRecipient(recipient.id)
}
recipients={envelope.recipients}
className="w-full"
align="end"
/>
</section>
</>
)}
</AnimateGenericFadeInOut>
)}
</div>
)}
</div>
);
};

View File

@ -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

@ -174,7 +174,7 @@ export const EnvelopeEditorSettingsDialog = ({
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, updateEnvelopeAsync } = useCurrentEnvelopeEditor();
const { envelope } = useCurrentEnvelopeEditor();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
@ -186,12 +186,14 @@ export const EnvelopeEditorSettingsDialog = ({
documentAuth: envelope.authOptions,
});
const createDefaultValues = () => {
return {
externalId: envelope.externalId || '',
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: {
externalId: envelope.externalId || '', // Todo: String or undefined?
visibility: envelope.visibility || '',
globalAccessAuth: documentAuthOption?.globalAccessAuth || [],
globalActionAuth: documentAuthOption?.globalActionAuth || [],
meta: {
subject: envelope.documentMeta.subject ?? '',
message: envelope.documentMeta.message ?? '',
@ -208,14 +210,11 @@ export const EnvelopeEditorSettingsDialog = ({
emailSettings: ZDocumentEmailSettingsSchema.parse(envelope.documentMeta.emailSettings),
signatureTypes: extractTeamSignatureSettings(envelope.documentMeta),
},
};
};
const form = useForm<TAddSettingsFormSchema>({
resolver: zodResolver(ZAddSettingsFormSchema),
defaultValues: createDefaultValues(),
},
});
const { mutateAsync: updateEnvelope } = trpc.envelope.update.useMutation();
const envelopeHasBeenSent =
envelope.type === EnvelopeType.DOCUMENT &&
envelope.recipients.some((recipient) => recipient.sendStatus === SendStatus.SENT);
@ -240,7 +239,8 @@ export const EnvelopeEditorSettingsDialog = ({
.safeParse(data.globalAccessAuth);
try {
await updateEnvelopeAsync({
await updateEnvelope({
envelopeId: envelope.id,
data: {
externalId: data.externalId || null,
visibility: data.visibility,
@ -295,7 +295,7 @@ export const EnvelopeEditorSettingsDialog = ({
]);
useEffect(() => {
form.reset(createDefaultValues());
form.reset();
setActiveTab('general');
}, [open, form]);

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,
@ -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,

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 { type Recipient, SigningStatus } from '@prisma/client';
import type Konva from 'konva';
import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer';
@ -9,23 +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 {
currentEnvelopeItem,
fields,
recipients,
getRecipientColorKey,
setRenderError,
overrideSettings,
} = useCurrentEnvelopeRender();
const { currentEnvelopeItem, fields, getRecipientColorKey, setRenderError } =
useCurrentEnvelopeRender();
const {
stage,
@ -41,65 +29,45 @@ export default function EnvelopeGenericPageRenderer() {
const { _className, scale } = pageContext;
const localPageFields = useMemo((): GenericLocalField[] => {
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}`);
}
return {
...field,
recipient,
};
});
}, [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 { recipient } = field;
const fieldTranslations = getClientSideFieldTranslations(i18n);
const isInserted = recipient.signingStatus === SigningStatus.SIGNED && field.inserted;
renderField({
scale,
pageLayer: pageLayer.current,
field: {
renderId: field.id.toString(),
...field,
customText: '',
width: Number(field.width),
height: Number(field.height),
positionX: Number(field.positionX),
positionY: Number(field.positionY),
customText: isInserted ? field.customText : '',
inserted: false,
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 ?? 'sign',
mode: 'sign',
});
};
const renderFieldOnLayer = (field: GenericLocalField) => {
const renderFieldOnLayer = (field: TEnvelope['fields'][number]) => {
try {
unsafeRenderFieldOnLayer(field);
} catch (err) {
@ -155,16 +123,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

@ -10,7 +10,6 @@ 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 { ZFullFieldSchema } from '@documenso/lib/types/field';
import { createSpinner } from '@documenso/lib/universal/field-renderer/field-generic-items';
@ -23,7 +22,6 @@ import { EnvelopeFieldToolTip } from '@documenso/ui/components/field/envelope-fi
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';
@ -62,8 +60,6 @@ export default function EnvelopeSignerPageRenderer() {
isDirectTemplate,
} = useRequiredEnvelopeSigningContext();
const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {};
const {
stage,
pageLayer,
@ -382,19 +378,7 @@ export default function EnvelopeSignerPageRenderer() {
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);
@ -429,6 +413,7 @@ export default function EnvelopeSignerPageRenderer() {
}
localPageFields.forEach((field) => {
console.log('Field changed/inserted, rendering on canvas');
renderFieldOnLayer(field);
});

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

@ -0,0 +1,49 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Download } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import type { DocumentData } from '@documenso/prisma/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type ShareDocumentDownloadButtonProps = {
title: string;
documentData: DocumentData;
};
export const ShareDocumentDownloadButton = ({
title,
documentData,
}: ShareDocumentDownloadButtonProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const onDownloadClick = async () => {
try {
setIsDownloading(true);
await downloadPDF({ documentData, fileName: title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
} finally {
setIsDownloading(false);
}
};
return (
<Button loading={isDownloading} onClick={onDownloadClick}>
{!isDownloading && <Download className="mr-2 h-4 w-4" />}
<Trans>Download</Trans>
</Button>
);
};

View File

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

View File

@ -1,14 +1,19 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircle, Download, Edit, EyeIcon, Pencil } from 'lucide-react';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { Button } from '@documenso/ui/primitives/button';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
@ -20,6 +25,8 @@ export type DocumentsTableActionButtonProps = {
export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonProps) => {
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
@ -37,6 +44,39 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.get.query(
{
documentId: row.id,
},
{
context: {
teamId: team?.id?.toString(),
},
},
)
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
throw Error('No document available');
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
@ -94,7 +134,7 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
<Trans>View</Trans>
</Button>
))
.with({ isComplete: true }, () => (
.with({ isComplete: true, internalVersion: 2 }, () => (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
@ -107,5 +147,11 @@ export const DocumentsTableActionButton = ({ row }: DocumentsTableActionButtonPr
}
/>
))
.with({ isComplete: true }, () => (
<Button className="w-32" onClick={onDownloadClick}>
<Download className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
))
.otherwise(() => <div></div>);
};

View File

@ -10,6 +10,7 @@ import {
Download,
Edit,
EyeIcon,
FileDown,
FolderInput,
Loader,
MoreHorizontal,
@ -19,10 +20,12 @@ import {
} from 'lucide-react';
import { Link } from 'react-router';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useSession } from '@documenso/lib/client-only/providers/session';
import type { TDocumentMany as TDocumentRow } from '@documenso/lib/types/document';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import {
DropdownMenu,
@ -31,6 +34,7 @@ import {
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@documenso/ui/primitives/dropdown-menu';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentDeleteDialog } from '~/components/dialogs/document-delete-dialog';
import { DocumentDuplicateDialog } from '~/components/dialogs/document-duplicate-dialog';
@ -52,6 +56,7 @@ export const DocumentsTableActionDropdown = ({
const { user } = useSession();
const team = useCurrentTeam();
const { toast } = useToast();
const { _ } = useLingui();
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -71,6 +76,58 @@ export const DocumentsTableActionDropdown = ({
const documentsPath = formatDocumentsPath(team.url);
const formatPath = `${documentsPath}/${row.envelopeId}/edit`;
const onDownloadClick = async () => {
try {
const document = !recipient
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const onDownloadOriginalClick = async () => {
try {
const document = !recipient
? await trpcClient.document.get.query({
documentId: row.id,
})
: await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
return;
}
await downloadPDF({ documentData, fileName: row.title, version: 'original' });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
const nonSignedRecipients = row.recipients.filter((item) => item.signingStatus !== 'SIGNED');
return (
@ -121,19 +178,33 @@ export const DocumentsTableActionDropdown = ({
</Link>
</DropdownMenuItem>
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
{row.internalVersion === 2 ? (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</div>
</DropdownMenuItem>
}
/>
) : (
<>
<DropdownMenuItem disabled={!isComplete} onClick={onDownloadClick}>
<Download className="mr-2 h-4 w-4" />
<Trans>Download</Trans>
</DropdownMenuItem>
}
/>
<DropdownMenuItem onClick={onDownloadOriginalClick}>
<FileDown className="mr-2 h-4 w-4" />
<Trans>Download Original</Trans>
</DropdownMenuItem>
</>
)}
<DropdownMenuItem onClick={() => setDuplicateDialogOpen(true)}>
<Copy className="mr-2 h-4 w-4" />
@ -202,8 +273,7 @@ export const DocumentsTableActionDropdown = ({
/>
<DocumentDuplicateDialog
id={row.envelopeId}
token={recipient?.token}
id={row.id}
open={isDuplicateDialogOpen}
onOpenChange={setDuplicateDialogOpen}
/>

View File

@ -10,9 +10,11 @@ import { DateTime } from 'luxon';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { trpc as trpcClient } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react';
import type { TFindInboxResponse } from '@documenso/trpc/server/document-router/find-inbox.types';
import { Button } from '@documenso/ui/primitives/button';
@ -26,7 +28,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
export type DocumentsTableProps = {
@ -198,6 +199,28 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
return null;
}
const onDownloadClick = async () => {
try {
const document = await trpcClient.document.getDocumentByToken.query({
token: recipient.token,
});
const documentData = document?.documentData;
if (!documentData) {
throw Error('No document available');
}
await downloadPDF({ documentData, fileName: row.title });
} catch (err) {
toast({
title: _(msg`Something went wrong`),
description: _(msg`An error occurred while downloading your document.`),
variant: 'destructive',
});
}
};
// TODO: Consider if want to keep this logic for hiding viewing for CC'ers
if (recipient?.role === RecipientRole.CC && isComplete === false) {
return null;
@ -207,7 +230,6 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
isPending,
isComplete,
isSigned,
internalVersion: row.internalVersion,
})
.with({ isPending: true, isSigned: false }, () => (
<Button className="w-32" asChild>
@ -241,17 +263,10 @@ export const InboxTableActionButton = ({ row }: InboxTableActionButtonProps) =>
</Button>
))
.with({ isComplete: true }, () => (
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
trigger={
<Button className="w-32">
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
}
/>
<Button className="w-32" onClick={onDownloadClick}>
<DownloadIcon className="-ml-1 mr-2 inline h-4 w-4" />
<Trans>Download</Trans>
</Button>
))
.otherwise(() => <div></div>);
};

View File

@ -147,13 +147,8 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
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" />
@ -186,10 +181,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
)}
<PDFViewer
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
document={envelope}
key={envelope.envelopeItems[0].id}
version="signed"
documentData={envelope.envelopeItems[0].documentData}
/>
</CardContent>
</Card>

View File

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

View File

@ -170,12 +170,8 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
<EnvelopeRenderProvider
envelope={envelope}
token={undefined}
fields={envelope.fields}
recipients={envelope.recipients}
overrideSettings={{
showRecipientTooltip: true,
}}
recipientIds={envelope.recipients.map((recipient) => recipient.id)}
>
{isMultiEnvelopeItem && (
<EnvelopeRendererFileSelector fields={envelope.fields} className="mb-4 p-0" />
@ -207,10 +203,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
/>
<PDFViewer
envelopeItem={envelope.envelopeItems[0]}
token={undefined}
version="signed"
document={envelope}
key={envelope.envelopeItems[0].id}
documentData={envelope.envelopeItems[0].documentData}
/>
</CardContent>
</Card>

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { useEffect } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { CheckCircle2, Clock8, DownloadIcon } from 'lucide-react';
import { CheckCircle2, Clock8, FileSearch } from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
@ -19,13 +20,14 @@ import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-emai
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { env } from '@documenso/lib/utils/env';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import DocumentDialog from '@documenso/ui/components/document/document-dialog';
import { DocumentDownloadButton } from '@documenso/ui/components/document/document-download-button';
import { DocumentShareButton } from '@documenso/ui/components/document/document-share-button';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { cn } from '@documenso/ui/lib/utils';
import { Badge } from '@documenso/ui/primitives/badge';
import { Button } from '@documenso/ui/primitives/button';
import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog';
import { ClaimAccount } from '~/components/general/claim-account';
import { DocumentSigningAuthPageView } from '~/components/general/document-signing/document-signing-auth-page';
@ -205,16 +207,24 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
<div className="mt-8 flex w-full max-w-sm items-center justify-center gap-4">
<DocumentShareButton documentId={document.id} token={recipient.token} />
{isDocumentCompleted(document.status) && (
<EnvelopeDownloadDialog
envelopeId={document.envelopeId}
envelopeStatus={document.status}
envelopeItems={document.envelopeItems}
token={recipient?.token}
{isDocumentCompleted(document.status) ? (
<DocumentDownloadButton
className="flex-1"
fileName={document.title}
documentData={document.documentData}
disabled={!isDocumentCompleted(document.status)}
/>
) : (
<DocumentDialog
documentData={document.documentData}
trigger={
<Button type="button" variant="outline" className="flex-1">
<DownloadIcon className="mr-2 h-5 w-5" />
<Trans>Download</Trans>
<Button
className="text-[11px]"
title={_(msg`Signatures will appear once the document has been completed`)}
variant="outline"
>
<FileSearch className="mr-2 h-5 w-5" strokeWidth={1.7} />
<Trans>View Original Document</Trans>
</Button>
}
/>

View File

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

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}
documentData={template.templateDocumentData}
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}
documentData={document.documentData}
recipient={recipient}
fields={fields}
completedFields={completedFields}
metadata={document.documentMeta}
isCompleted={isDocumentCompleted(document.status)}
hidePoweredBy={hidePoweredBy}
allowWhitelabelling={allowEmbedSigningWhitelabel}
allRecipients={allRecipients}
/>
</DocumentSigningAuthProvider>
</DocumentSigningProvider>
);
}

View File

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

@ -25,7 +25,6 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@epic-web/remember": "^1.1.0",
"@faker-js/faker": "^10.1.0",
"@hono/node-server": "^1.13.7",
"@hono/trpc-server": "^0.3.4",
"@hookform/resolvers": "^3.1.0",
@ -41,7 +40,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 +86,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 +103,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.0.1"
}
"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

@ -1,5 +1,4 @@
import { sValidator } from '@hono/standard-validator';
import type { Prisma } from '@prisma/client';
import { Hono } from 'hono';
import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session';
@ -10,7 +9,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,
@ -207,28 +206,17 @@ export const filesRoute = new Hono<HonoEnv>()
async (c) => {
const { token, envelopeItemId } = c.req.valid('param');
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
const envelopeItem = await prisma.envelopeItem.findFirst({
where: {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
},
};
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,
@ -259,28 +247,17 @@ export const filesRoute = new Hono<HonoEnv>()
async (c) => {
const { token, envelopeItemId, version } = c.req.valid('param');
let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
const envelopeItem = await prisma.envelopeItem.findFirst({
where: {
id: envelopeItemId,
envelope: {
recipients: {
some: {
token,
},
},
},
},
};
if (token.startsWith('qr_')) {
envelopeWhereQuery = {
id: envelopeItemId,
envelope: {
qrToken: token,
},
};
}
const envelopeItem = await prisma.envelopeItem.findUnique({
where: envelopeWhereQuery,
include: {
envelope: true,
documentData: true,

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,

195
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.0.1",
"version": "1.13.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.0.1",
"version": "1.13.1",
"workspaces": [
"apps/*",
"packages/*"
@ -45,7 +45,6 @@
"prettier": "^3.3.3",
"prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1",
"superjson": "^2.2.5",
@ -100,7 +99,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.0.1",
"version": "1.13.1",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.2",
"@documenso/api": "*",
@ -113,7 +112,6 @@
"@documenso/trpc": "*",
"@documenso/ui": "*",
"@epic-web/remember": "^1.1.0",
"@faker-js/faker": "^10.1.0",
"@hono/node-server": "^1.13.7",
"@hono/trpc-server": "^0.3.4",
"@hookform/resolvers": "^3.1.0",
@ -129,7 +127,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 +173,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",
@ -3515,22 +3511,6 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@faker-js/faker": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz",
"integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
@ -7619,18 +7599,6 @@
"empathic": "2.0.0"
}
},
"node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"license": "Apache-2.0"
},
"node_modules/@prisma/dmmf": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.18.0.tgz",
"integrity": "sha512-x0ItbLDxAnciEMFnGUm90Bkpzx2ja5Lp8Lz+9UkepIClZOMC4WvtMUHaMMfCFqfoBb+KUbxa/xV+FJ6EAaw4wQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz",
@ -7650,6 +7618,12 @@
"integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==",
"license": "Apache-2.0"
},
"node_modules/@prisma/engines/node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"license": "Apache-2.0"
},
"node_modules/@prisma/fetch-engine": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz",
@ -7661,10 +7635,10 @@
"@prisma/get-platform": "6.18.0"
}
},
"node_modules/@prisma/generator": {
"node_modules/@prisma/fetch-engine/node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.18.0.tgz",
"integrity": "sha512-8Sz9z8d/X/42uL07qYF4no2hnSSMPk8g6+w0zpINCN7lvnILby5b56xA0uPKRFfR3Wfn3NtFLnyEpMQE9fXeeg==",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"license": "Apache-2.0"
},
"node_modules/@prisma/generator-helper": {
@ -7745,6 +7719,12 @@
"@prisma/debug": "6.18.0"
}
},
"node_modules/@prisma/get-platform/node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"license": "Apache-2.0"
},
"node_modules/@prisma/internals": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@prisma/internals/-/internals-5.3.1.tgz",
@ -12317,13 +12297,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",
@ -28673,54 +28646,6 @@
"@prisma/client": "latest"
}
},
"node_modules/prisma-json-types-generator": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
"license": "MIT",
"dependencies": {
"@prisma/generator-helper": "^6.16.1",
"semver": "^7.7.2",
"tslib": "^2.8.1"
},
"bin": {
"prisma-json-types-generator": "index.js"
},
"engines": {
"node": ">=14.0"
},
"funding": {
"url": "https://github.com/arthurfiorette/prisma-json-types-generator?sponsor=1"
},
"peerDependencies": {
"@prisma/client": "^6.14",
"prisma": "^6.14",
"typescript": "^5.9.2"
}
},
"node_modules/prisma-json-types-generator/node_modules/@prisma/generator-helper": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.18.0.tgz",
"integrity": "sha512-kmlCDRRewLwBkHpkAjzyuNHD5ISlDLzUTcTsZbwmjDilQVt/S72xvvCAa+hxY16APTkqbtDYn3p7zL/XFO+C0A==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0",
"@prisma/dmmf": "6.18.0",
"@prisma/generator": "6.18.0"
}
},
"node_modules/prisma-json-types-generator/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prisma-kysely": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/prisma-kysely/-/prisma-kysely-1.8.0.tgz",
@ -36531,6 +36456,24 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zod-prisma-types/node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"license": "Apache-2.0"
},
"node_modules/zod-prisma-types/node_modules/@prisma/dmmf": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.18.0.tgz",
"integrity": "sha512-x0ItbLDxAnciEMFnGUm90Bkpzx2ja5Lp8Lz+9UkepIClZOMC4WvtMUHaMMfCFqfoBb+KUbxa/xV+FJ6EAaw4wQ==",
"license": "Apache-2.0"
},
"node_modules/zod-prisma-types/node_modules/@prisma/generator": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.18.0.tgz",
"integrity": "sha512-8Sz9z8d/X/42uL07qYF4no2hnSSMPk8g6+w0zpINCN7lvnILby5b56xA0uPKRFfR3Wfn3NtFLnyEpMQE9fXeeg==",
"license": "Apache-2.0"
},
"node_modules/zod-prisma-types/node_modules/@prisma/generator-helper": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.18.0.tgz",
@ -37187,6 +37130,72 @@
"typescript": "5.6.2"
}
},
"packages/prisma/node_modules/@prisma/debug": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
"license": "Apache-2.0"
},
"packages/prisma/node_modules/@prisma/dmmf": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-6.18.0.tgz",
"integrity": "sha512-x0ItbLDxAnciEMFnGUm90Bkpzx2ja5Lp8Lz+9UkepIClZOMC4WvtMUHaMMfCFqfoBb+KUbxa/xV+FJ6EAaw4wQ==",
"license": "Apache-2.0"
},
"packages/prisma/node_modules/@prisma/generator": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-6.18.0.tgz",
"integrity": "sha512-8Sz9z8d/X/42uL07qYF4no2hnSSMPk8g6+w0zpINCN7lvnILby5b56xA0uPKRFfR3Wfn3NtFLnyEpMQE9fXeeg==",
"license": "Apache-2.0"
},
"packages/prisma/node_modules/@prisma/generator-helper": {
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-6.18.0.tgz",
"integrity": "sha512-kmlCDRRewLwBkHpkAjzyuNHD5ISlDLzUTcTsZbwmjDilQVt/S72xvvCAa+hxY16APTkqbtDYn3p7zL/XFO+C0A==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "6.18.0",
"@prisma/dmmf": "6.18.0",
"@prisma/generator": "6.18.0"
}
},
"packages/prisma/node_modules/prisma-json-types-generator": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/prisma-json-types-generator/-/prisma-json-types-generator-3.6.2.tgz",
"integrity": "sha512-WX/oENQ0S74r/Wgd2uuHT5i3KbnwLFCP2Fq5ISzrXkus/htOC4uCaQPYuGP2m/wSeKZZCw1RxptTlD+ib7Ht/A==",
"license": "MIT",
"dependencies": {
"@prisma/generator-helper": "^6.16.1",
"semver": "^7.7.2",
"tslib": "^2.8.1"
},
"bin": {
"prisma-json-types-generator": "index.js"
},
"engines": {
"node": ">=14.0"
},
"funding": {
"url": "https://github.com/arthurfiorette/prisma-json-types-generator?sponsor=1"
},
"peerDependencies": {
"@prisma/client": "^6.14",
"prisma": "^6.14",
"typescript": "^5.9.2"
}
},
"packages/prisma/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"packages/prisma/node_modules/ts-pattern": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.7.1.tgz",

View File

@ -1,6 +1,6 @@
{
"private": true,
"version": "2.0.1",
"version": "1.13.1",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
@ -56,7 +56,6 @@
"prettier": "^3.3.3",
"prisma": "^6.18.0",
"prisma-extension-kysely": "^3.0.0",
"prisma-json-types-generator": "^3.6.2",
"prisma-kysely": "^1.8.0",
"rimraf": "^5.0.1",
"turbo": "^1.9.3",
@ -95,4 +94,4 @@
"trigger.dev": {
"endpointId": "documenso-app"
}
}
}

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,
@ -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();
@ -551,10 +559,5 @@ test.describe('API V2 Envelopes', () => {
);
expect(finalEnvelope.title).toBe('Envelope Full Field Test');
expect(finalEnvelope.type).toBe(EnvelopeType.DOCUMENT);
console.log({
createdEnvelopeId: finalEnvelope.id,
userEmail: userA.email,
});
});
});

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

@ -25,7 +25,8 @@ 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 { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import { seedAlignmentTestDocument } from '@documenso/prisma/seed/initial-seed';
import { seedUser } from '@documenso/prisma/seed/users';
@ -34,7 +35,7 @@ import { apiSignin } from '../fixtures/authentication';
test.describe.configure({ mode: 'parallel', timeout: 60000 });
test.skip('field placement visual regression', async ({ page }, testInfo) => {
test('field placement visual regression', async ({ page }, testInfo) => {
const { user, team } = await seedUser();
const envelope = await seedAlignmentTestDocument({
@ -94,14 +95,7 @@ test.skip('field placement visual regression', async ({ page }, testInfo) => {
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const pdfData = await getFile(item.documentData);
const loadedImages = storedImages
.filter((image) => image.includes(item.title))
@ -109,7 +103,7 @@ test.skip('field placement visual regression', async ({ page }, testInfo) => {
await compareSignedPdfWithImages({
id: item.title.replaceAll(' ', '-').toLowerCase(),
pdfData: new Uint8Array(pdfData),
pdfData,
images: loadedImages,
testInfo,
});
@ -180,16 +174,9 @@ test.skip('download envelope images', async ({ page }) => {
await Promise.all(
completedDocument.envelopeItems.map(async (item) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: item,
token,
version: 'signed',
});
const pdfData = await getFile(item.documentData);
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const pdfImages = await renderPdfToImage(new Uint8Array(pdfData));
const pdfImages = await renderPdfToImage(pdfData);
for (const [index, { image }] of pdfImages.entries()) {
fs.writeFileSync(

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 { getFile } from '@documenso/lib/universal/upload/get-file';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedTeam } from '@documenso/prisma/seed/teams';
@ -25,26 +25,20 @@ test.describe('Signing Certificate Tests', () => {
teamId: team.id,
});
const recipient = recipients[0];
const documentData = await prisma.envelopeItem
const documentData = await prisma.documentData
.findFirstOrThrow({
where: {
envelopeId: document.id,
envelopeItem: {
envelopeId: document.id,
},
},
})
.then(async (data) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: data,
token: recipient.token,
version: 'signed',
});
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
.then(async (data) => getFile(data));
const originalPdf = await PDFDocument.load(documentData);
const recipient = recipients[0];
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@ -84,18 +78,9 @@ test.describe('Signing Certificate Tests', () => {
},
});
const firstDocumentData = completedDocument.envelopeItems[0];
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: firstDocumentData,
token: recipient.token,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const completedDocumentData = new Uint8Array(pdfData);
const completedDocumentData = await getFile(firstDocumentData);
// Load the PDF and check number of pages
const pdfDoc = await PDFDocument.load(completedDocumentData);
@ -132,26 +117,20 @@ test.describe('Signing Certificate Tests', () => {
},
});
const recipient = recipients[0];
const documentData = await prisma.envelopeItem
const documentData = await prisma.documentData
.findFirstOrThrow({
where: {
envelopeId: document.id,
envelopeItem: {
envelopeId: document.id,
},
},
})
.then(async (data) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: data,
token: recipient.token,
version: 'signed',
});
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
.then(async (data) => getFile(data));
const originalPdf = await PDFDocument.load(documentData);
const recipient = recipients[0];
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@ -189,18 +168,9 @@ test.describe('Signing Certificate Tests', () => {
},
});
const firstDocumentData = completedDocument.envelopeItems[0];
const firstDocumentData = completedDocument.envelopeItems[0].documentData;
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: firstDocumentData,
token: recipient.token,
version: 'signed',
});
const pdfData = await fetch(documentUrl).then(async (res) => await res.arrayBuffer());
const completedDocumentData = new Uint8Array(pdfData);
const completedDocumentData = await getFile(firstDocumentData);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);
@ -237,25 +207,19 @@ test.describe('Signing Certificate Tests', () => {
},
});
const recipient = recipients[0];
const documentData = await prisma.envelopeItem
const documentData = await prisma.documentData
.findFirstOrThrow({
where: {
envelopeId: document.id,
envelopeItem: {
envelopeId: document.id,
},
},
})
.then(async (data) => {
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: data,
token: recipient.token,
version: 'signed',
});
return fetch(documentUrl).then(async (res) => await res.arrayBuffer());
});
.then(async (data) => getFile(data));
const originalPdf = await PDFDocument.load(new Uint8Array(documentData));
const originalPdf = await PDFDocument.load(documentData);
const recipient = recipients[0];
// Sign the document
await page.goto(`/sign/${recipient.token}`);
@ -294,16 +258,7 @@ test.describe('Signing Certificate Tests', () => {
},
});
const documentUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: completedDocument.envelopeItems[0],
token: recipient.token,
version: 'signed',
});
const completedDocumentData = await fetch(documentUrl).then(
async (res) => await res.arrayBuffer(),
);
const completedDocumentData = await getFile(completedDocument.envelopeItems[0].documentData);
// Load the PDF and check number of pages
const completedPdf = await PDFDocument.load(completedDocumentData);

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.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

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

@ -1,14 +1,12 @@
import type { EnvelopeItem } from '@prisma/client';
import type { DocumentData } from '@prisma/client';
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
import { getFile } from '../universal/upload/get-file';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
type DownloadPDFProps = {
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
documentData: DocumentData;
fileName?: string;
/**
* Specifies which version of the document to download.
@ -19,19 +17,18 @@ type DownloadPDFProps = {
};
export const downloadPDF = async ({
envelopeItem,
token,
documentData,
fileName,
version = 'signed',
}: DownloadPDFProps) => {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'download',
envelopeItem: envelopeItem,
token,
version,
const bytes = await getFile({
type: documentData.type,
data: version === 'signed' ? documentData.data : documentData.initialData,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const blob = new Blob([bytes], {
type: 'application/pdf',
});
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';

View File

@ -46,7 +46,6 @@ type EnvelopeEditorProviderValue = {
setLocalEnvelope: (localEnvelope: Partial<TEnvelope>) => void;
updateEnvelope: (envelopeUpdates: UpdateEnvelopePayload) => void;
updateEnvelopeAsync: (envelopeUpdates: UpdateEnvelopePayload) => Promise<void>;
setRecipientsDebounced: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => void;
setRecipientsAsync: (recipients: TSetEnvelopeRecipientsRequest['recipients']) => Promise<void>;
@ -67,6 +66,8 @@ type EnvelopeEditorProviderValue = {
};
syncEnvelope: () => Promise<void>;
// refetchEnvelope: () => Promise<void>;
// updateEnvelope: (envelope: TEnvelope) => Promise<void>;
};
interface EnvelopeEditorProviderProps {
@ -150,7 +151,7 @@ export const EnvelopeEditorProvider = ({
});
const envelopeRecipientSetMutationQuery = trpc.envelope.recipient.set.useMutation({
onSuccess: ({ data: recipients }) => {
onSuccess: ({ recipients }) => {
setEnvelope((prev) => ({
...prev,
recipients,
@ -196,7 +197,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) {
@ -235,13 +236,6 @@ export const EnvelopeEditorProvider = ({
setEnvelopeDebounced(envelopeUpdates);
};
const updateEnvelopeAsync = async (envelopeUpdates: UpdateEnvelopePayload) => {
await envelopeUpdateMutationQuery.mutateAsync({
envelopeId: envelope.id,
...envelopeUpdates,
});
};
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = envelope.recipients.findIndex(
@ -329,7 +323,6 @@ export const EnvelopeEditorProvider = ({
setLocalEnvelope,
getRecipientColorKey,
updateEnvelope,
updateEnvelopeAsync,
setRecipientsDebounced,
setRecipientsAsync,
editorFields,

View File

@ -1,14 +1,13 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import React from 'react';
import type { Field, Recipient } from '@prisma/client';
import type { DocumentData } 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 { getFile } from '../../universal/upload/get-file';
type FileData =
| {
@ -19,31 +18,22 @@ type FileData =
status: 'loaded';
};
type EnvelopeRenderOverrideSettings = {
mode?: FieldRenderMode;
showRecipientTooltip?: boolean;
showRecipientSigningStatus?: boolean;
};
type EnvelopeRenderItem = TEnvelope['envelopeItems'][number];
type EnvelopeRenderProviderValue = {
getPdfBuffer: (envelopeItemId: string) => FileData | null;
getPdfBuffer: (documentDataId: string) => FileData | null;
envelopeItems: EnvelopeRenderItem[];
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;
setRenderError: (renderError: boolean) => void;
overrideSettings?: EnvelopeRenderOverrideSettings;
};
interface EnvelopeRenderProviderProps {
children: React.ReactNode;
envelope: Pick<TEnvelope, 'envelopeItems'>;
/**
@ -51,27 +41,14 @@ interface EnvelopeRenderProviderProps {
*
* 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'>[];
/**
* The token to access the envelope.
*
* If not provided, it will be assumed that the current user can access the document.
*/
token: string | undefined;
/**
* Custom override settings for generic page renderers.
*/
overrideSettings?: EnvelopeRenderOverrideSettings;
recipientIds?: number[];
}
const EnvelopeRenderContext = createContext<EnvelopeRenderProviderValue | null>(null);
@ -93,9 +70,7 @@ export const EnvelopeRenderProvider = ({
children,
envelope,
fields,
token,
recipients = [],
overrideSettings,
recipientIds = [],
}: EnvelopeRenderProviderProps) => {
// Indexed by documentDataId.
const [files, setFiles] = useState<Record<string, FileData>>({});
@ -109,35 +84,27 @@ export const EnvelopeRenderProvider = ({
[envelope.envelopeItems],
);
const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => {
if (files[envelopeItem.id]?.status === 'loading') {
const loadEnvelopeItemPdfFile = async (documentData: DocumentData) => {
if (files[documentData.id]?.status === 'loading') {
return;
}
if (!files[envelopeItem.id]) {
if (!files[documentData.id]) {
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
[documentData.id]: {
status: 'loading',
},
}));
}
try {
const downloadUrl = getEnvelopeItemPdfUrl({
type: 'view',
envelopeItem: envelopeItem,
token,
});
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const file = await blob.arrayBuffer();
const file = await getFile(documentData);
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
file: new Uint8Array(file),
[documentData.id]: {
file,
status: 'loaded',
},
}));
@ -146,7 +113,7 @@ export const EnvelopeRenderProvider = ({
setFiles((prev) => ({
...prev,
[envelopeItem.id]: {
[documentData.id]: {
status: 'error',
},
}));
@ -154,8 +121,8 @@ export const EnvelopeRenderProvider = ({
};
const getPdfBuffer = useCallback(
(envelopeItemId: string) => {
return files[envelopeItemId] || null;
(documentDataId: string) => {
return files[documentDataId] || null;
},
[files],
);
@ -175,18 +142,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);
void loadEnvelopeItemPdfFile(item.documentData);
}
}, [envelope.envelopeItems]);
const recipientIds = useMemo(
() => recipients.map((recipient) => recipient.id).sort(),
[recipients],
);
const getRecipientColorKey = useCallback(
(recipientId: number) => {
const recipientIndex = recipientIds.findIndex((id) => id === recipientId);
@ -206,11 +168,9 @@ export const EnvelopeRenderProvider = ({
currentEnvelopeItem: currentItem,
setCurrentEnvelopeItem,
fields: fields ?? [],
recipients,
getRecipientColorKey,
renderError,
setRenderError,
overrideSettings,
}}
>
{children}

View File

@ -191,7 +191,7 @@ export const run = async ({
const newDocumentData = await Promise.all(
envelopeItems.map(async (envelopeItem) =>
io.runTask(`decorate-${envelopeItem.id}`, async () => {
io.runTask(`decorate-and-sign-envelope-item-${envelopeItem.id}`, async () => {
const envelopeItemFields = envelope.envelopeItems.find(
(item) => item.id === envelopeItem.id,
)?.field;

View File

@ -78,14 +78,6 @@ export const adminFindDocuments = async ({
url: true,
},
},
envelopeItems: {
select: {
id: true,
envelopeId: true,
title: true,
order: true,
},
},
},
}),
prisma.envelope.count({

View File

@ -248,14 +248,6 @@ export const findDocuments = async ({
url: true,
},
},
envelopeItems: {
select: {
id: true,
envelopeId: true,
title: true,
order: true,
},
},
},
}),
prisma.envelope.count({

View File

@ -92,10 +92,6 @@ export const getDocumentAndSenderByToken = async ({
},
envelopeItems: {
select: {
id: true,
title: true,
order: true,
envelopeId: true,
documentData: true,
},
},

View File

@ -63,8 +63,5 @@ export const getDocumentWithDetailsById = async ({
documentId: legacyDocumentId,
password: null,
},
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
...envelopeItem,
})),
};
};

View File

@ -143,7 +143,7 @@ export const getEnvelopeForDirectTemplateSigning = async ({
envelope,
recipient: {
...recipient,
directToken: envelope.directLink?.token || '',
token: envelope.directLink?.token || '',
},
recipientSignature: null,
isRecipientsTurn: true,

View File

@ -2,6 +2,7 @@ import { DocumentSigningOrder, DocumentStatus, EnvelopeType, SigningStatus } fro
import { z } from 'zod';
import { prisma } from '@documenso/prisma';
import DocumentDataSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import DocumentMetaSchema from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import EnvelopeItemSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeItemSchema';
import EnvelopeSchema from '@documenso/prisma/generated/zod/modelSchema/EnvelopeSchema';
@ -71,11 +72,20 @@ export const ZEnvelopeForSigningResponse = z.object({
.array(),
envelopeItems: EnvelopeItemSchema.pick({
envelopeId: true,
id: true,
title: true,
documentDataId: true,
order: true,
}).array(),
})
.extend({
documentData: DocumentDataSchema.pick({
type: true,
id: true,
data: true,
initialData: true,
}),
})
.array(),
team: TeamSchema.pick({
id: true,
@ -107,7 +117,6 @@ export const ZEnvelopeForSigningResponse = z.object({
signingOrder: true,
rejectionReason: true,
}).extend({
directToken: z.string().nullish(),
fields: ZFieldSchema.omit({
documentId: true,
templateId: true,
@ -190,7 +199,11 @@ export const getEnvelopeForRecipientSigning = async ({
signingOrder: 'asc',
},
},
envelopeItems: true,
envelopeItems: {
include: {
documentData: true,
},
},
team: {
select: {
id: true,

View File

@ -82,7 +82,6 @@ type CreatedDirectRecipientField = {
export const ZCreateDocumentFromDirectTemplateResponseSchema = z.object({
token: z.string(),
envelopeId: z.string(),
documentId: z.number(),
recipientId: z.number(),
});
@ -816,7 +815,6 @@ export const createDocumentFromDirectTemplate = async ({
return {
token,
envelopeId: createdEnvelope.id,
documentId: incrementedDocumentId.documentId,
recipientId,
};

View File

@ -87,9 +87,5 @@ export const getTemplateByDirectLinkToken = async ({
},
recipients: recipientsWithMappedFields,
fields: recipientsWithMappedFields.flatMap((recipient) => recipient.fields),
envelopeItems: envelope.envelopeItems.map((item) => ({
id: item.id,
envelopeId: item.envelopeId,
})),
};
};

View File

@ -29,7 +29,6 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
envelopeItems: {
select: {
id: true,
envelopeId: true,
documentData: true,
},
},
@ -95,9 +94,5 @@ export const getTemplateById = async ({ id, userId, teamId }: GetTemplateByIdOpt
}
: null,
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
envelopeItems: envelope.envelopeItems.map((envelopeItem) => ({
id: envelopeItem.id,
envelopeId: envelopeItem.envelopeId,
})),
};
};

File diff suppressed because it is too large Load Diff

View File

@ -53,7 +53,7 @@ msgstr "\"Team Name\" has invited you to sign \"example document\"."
#: apps/remix/app/components/general/envelope-signing/envelope-signer-form.tsx
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "(You)"
msgstr "(You)"
@ -1076,10 +1076,6 @@ msgstr "Add Placeholder Recipient"
msgid "Add Placeholders"
msgstr "Add Placeholders"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Add Recipients"
msgstr "Add Recipients"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@ -1128,8 +1124,6 @@ msgstr "Add this URL to your provider's allowed redirect URIs"
msgid "Additional brand information to display at the bottom of emails"
msgstr "Additional brand information to display at the bottom of emails"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: packages/lib/constants/teams-translations.ts
#: packages/lib/constants/organisations-translations.ts
msgid "Admin"
@ -1369,6 +1363,17 @@ msgstr "An error occurred while disabling direct link signing."
msgid "An error occurred while disabling the user."
msgstr "An error occurred while disabling the user."
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/share-document-download-button.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-button.tsx
msgid "An error occurred while downloading your document."
msgstr "An error occurred while downloading your document."
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
msgid "An error occurred while duplicating template."
msgstr "An error occurred while duplicating template."
@ -1450,10 +1455,6 @@ msgstr "An error occurred while signing as assistant."
msgid "An error occurred while signing the document."
msgstr "An error occurred while signing the document."
#: apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
msgid "An error occurred while signing the field."
msgstr "An error occurred while signing the field."
#: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/billing-plans.tsx
msgid "An error occurred while trying to create a checkout session."
@ -1537,7 +1538,6 @@ msgstr "An unexpected error occurred."
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-document-delete-dialog.tsx
#: apps/remix/app/components/dialogs/account-delete-dialog.tsx
@ -1694,7 +1694,7 @@ msgstr "Assist"
msgid "Assist Document"
msgstr "Assist Document"
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Assist with signing"
msgstr "Assist with signing"
@ -2038,7 +2038,6 @@ msgstr "Can prepare"
#: apps/remix/app/components/dialogs/claim-delete-dialog.tsx
#: apps/remix/app/components/dialogs/claim-create-dialog.tsx
#: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
#: packages/ui/primitives/signature-pad/signature-pad-dialog.tsx
#: packages/ui/primitives/document-flow/send-document-action-dialog.tsx
@ -2166,6 +2165,10 @@ msgstr "Clear filters"
msgid "Clear Signature"
msgstr "Clear Signature"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Click here to add a recipient"
msgstr "Click here to add a recipient"
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
msgid "Click here to get started"
msgstr "Click here to get started"
@ -2188,7 +2191,7 @@ msgstr "Click to copy signing link for sending to recipient"
#: apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
msgid "Click to insert field"
@ -2231,6 +2234,10 @@ msgstr "Client secret is required"
msgid "Close"
msgstr "Close"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
msgid "Coming soon"
msgstr "Coming soon"
#: apps/remix/app/components/embed/authoring/configure-document-advanced-settings.tsx
msgid "Communication"
msgstr "Communication"
@ -2242,8 +2249,8 @@ msgstr "Compare all plans and features in detail"
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
@ -2289,12 +2296,6 @@ msgstr "Completed Documents"
msgid "Completed on {formattedDate}"
msgstr "Completed on {formattedDate}"
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
msgid "Configuration Error"
msgstr "Configuration Error"
#. placeholder {0}: parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])
#: apps/remix/app/components/embed/authoring/field-advanced-settings-drawer.tsx
msgid "Configure {0} Field"
@ -3566,17 +3567,20 @@ msgstr "Domain Name"
msgid "Don't have an account? <0>Sign up</0>"
msgstr "Don't have an account? <0>Sign up</0>"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/share-document-download-button.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-button.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-certificate-qr-view.tsx
#: apps/remix/app/components/general/document/document-page-view-button.tsx
#: apps/remix/app/components/forms/2fa/view-recovery-codes-dialog.tsx
#: apps/remix/app/components/forms/2fa/enable-authenticator-app-dialog.tsx
#: packages/ui/components/document/document-download-button.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
msgstr "Download"
@ -3593,6 +3597,11 @@ msgstr "Download Certificate"
msgid "Download Files"
msgstr "Download Files"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Download Original"
msgstr "Download Original"
#: apps/remix/app/components/general/envelope-signing/envelope-signer-header.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor.tsx
#: apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx
@ -3743,7 +3752,7 @@ msgstr "Electronic Signature Disclosure"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/forgot-password.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
@ -4014,6 +4023,10 @@ msgstr "Enter your text here"
msgid "Enterprise"
msgstr "Enterprise"
#: packages/ui/primitives/document-upload.tsx
msgid "Envelope (beta)"
msgstr "Envelope (beta)"
#: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx
msgid "Envelope distributed"
msgstr "Envelope distributed"
@ -4059,6 +4072,7 @@ msgstr "Envelope updated"
#: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
#: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx
#: apps/remix/app/components/general/verify-email-banner.tsx
#: apps/remix/app/components/general/template/template-edit-form.tsx
@ -4067,7 +4081,6 @@ msgstr "Envelope updated"
#: apps/remix/app/components/general/template/template-edit-form.tsx
#: apps/remix/app/components/general/template/template-edit-form.tsx
#: apps/remix/app/components/general/template/template-edit-form.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
#: apps/remix/app/components/general/document-signing/document-signing-signature-field.tsx
@ -4416,7 +4429,7 @@ msgstr "Free Signature Settings"
#: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
msgid "Full Name"
@ -4561,7 +4574,7 @@ msgstr "has invited you to view this document"
msgid "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
msgstr "Having an assistant as the last signer means they will be unable to take any action as there are no subsequent signers to assist."
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Help complete the document for other signers."
msgstr "Help complete the document for other signers."
@ -5239,8 +5252,6 @@ msgstr "Manage your passkeys."
msgid "Manage your site settings here"
msgstr "Manage your site settings here"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: packages/lib/constants/teams-translations.ts
#: packages/lib/constants/organisations-translations.ts
msgid "Manager"
@ -5289,8 +5300,6 @@ msgstr "Maximum number of uploaded files per envelope allowed"
#: apps/remix/app/components/tables/team-members-table.tsx
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: packages/lib/constants/teams-translations.ts
#: packages/lib/constants/organisations-translations.ts
msgid "Member"
@ -5301,6 +5310,10 @@ msgstr "Member"
msgid "Member Count"
msgstr "Member Count"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Member promoted to owner successfully"
msgstr "Member promoted to owner successfully"
#: apps/remix/app/components/tables/organisation-members-table.tsx
msgid "Member Since"
msgstr "Member Since"
@ -5333,10 +5346,6 @@ msgstr "Message <0>(Optional)</0>"
msgid "Min"
msgstr "Min"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx
msgid "Missing Recipients"
msgstr "Missing Recipients"
#: apps/remix/app/components/general/template/template-page-view-recipients.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
msgid "Modify recipients"
@ -5475,7 +5484,7 @@ msgstr "New Password"
msgid "New Template"
msgstr "New Template"
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
@ -5923,15 +5932,9 @@ msgstr "Override organisation settings"
#: apps/remix/app/components/tables/organisation-members-table.tsx
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/tables/admin-organisations-table.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "Owner"
msgstr "Owner"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "Ownership transferred to {organisationMemberName}."
msgstr "Ownership transferred to {organisationMemberName}."
#. placeholder {0}: table.getState().pagination.pageIndex + 1
#. placeholder {1}: table.getPageCount() || 1
#: packages/ui/primitives/data-table-pagination.tsx
@ -6036,7 +6039,6 @@ msgstr "PDF Document"
#: apps/remix/app/components/general/envelope-editor/envelope-editor-header.tsx
#: apps/remix/app/components/general/document/document-status.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
#: packages/ui/components/document/document-read-only-fields.tsx
#: packages/lib/constants/document.ts
msgid "Pending"
@ -6374,6 +6376,10 @@ msgstr "Profile updated"
msgid "Progress"
msgstr "Progress"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "Promote to owner"
msgstr "Promote to owner"
#: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx
msgid "Provider"
msgstr "Provider"
@ -6874,7 +6880,6 @@ msgstr "Right"
#: apps/remix/app/components/dialogs/team-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/team-group-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "Role"
msgstr "Role"
@ -7334,7 +7339,7 @@ msgstr "Sign as<0>{0} <1>({1})</1></0>"
msgid "Sign Checkbox Field"
msgstr "Sign Checkbox Field"
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
msgid "Sign document"
@ -7402,7 +7407,7 @@ msgstr "Sign Signature Field"
msgid "Sign Text Field"
msgstr "Sign Text Field"
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
msgid "Sign the document to complete the process."
@ -7444,7 +7449,7 @@ msgstr "Sign your initials into the field"
#: apps/remix/app/components/general/document-signing/document-signing-form.tsx
#: apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx
#: packages/ui/primitives/template-flow/add-template-fields.tsx
@ -7478,10 +7483,13 @@ msgstr "Signature types"
msgid "Signatures Collected"
msgstr "Signatures Collected"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "Signatures will appear once the document has been completed"
msgstr "Signatures will appear once the document has been completed"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
#: apps/remix/app/components/general/document/document-page-view-recipients.tsx
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
#: packages/ui/components/document/envelope-recipient-field-tooltip.tsx
#: packages/ui/components/document/document-read-only-fields.tsx
msgid "Signed"
msgstr "Signed"
@ -7527,7 +7535,7 @@ msgstr "Signing certificate provided by"
msgid "Signing Complete!"
msgstr "Signing Complete!"
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
msgid "Signing for"
msgstr "Signing for"
@ -7587,6 +7595,11 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
#: apps/remix/app/components/tables/organisation-member-invites-table.tsx
#: apps/remix/app/components/tables/organisation-member-invites-table.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/documents-table-action-button.tsx
#: apps/remix/app/components/general/share-document-download-button.tsx
#: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
@ -7597,14 +7610,16 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
#: apps/remix/app/components/general/organisations/organisation-billing-portal-button.tsx
#: apps/remix/app/components/general/organisations/organisation-billing-banner.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/components/general/document/document-page-view-button.tsx
#: apps/remix/app/components/general/document/document-certificate-download-button.tsx
#: apps/remix/app/components/general/document/document-audit-log-download-button.tsx
#: apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx
#: apps/remix/app/components/general/direct-template/direct-template-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
#: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx
@ -7643,11 +7658,6 @@ msgstr "Something went wrong while loading the document."
msgid "Something went wrong while loading your passkeys."
msgstr "Something went wrong while loading your passkeys."
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
msgid "Something went wrong while rendering the document, some fields may be missing or corrupted."
msgstr "Something went wrong while rendering the document, some fields may be missing or corrupted."
#: apps/remix/app/components/general/verify-email-banner.tsx
msgid "Something went wrong while sending the confirmation email."
msgstr "Something went wrong while sending the confirmation email."
@ -7798,6 +7808,7 @@ msgstr "Subscription invalid"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx
#: apps/remix/app/components/tables/organisation-member-invites-table.tsx
@ -7842,7 +7853,6 @@ msgstr "Subscription invalid"
#: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-create-dialog.tsx
#: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx
msgid "Success"
msgstr "Success"
@ -8513,10 +8523,6 @@ msgstr "The token you have used to reset your password is either expired or it n
msgid "The two-factor authentication code provided is incorrect"
msgstr "The two-factor authentication code provided is incorrect"
#: apps/remix/app/components/forms/editor/editor-field-signature-form.tsx
msgid "The typed signature font size"
msgstr "The typed signature font size"
#: packages/ui/components/document/document-signature-settings-tooltip.tsx
msgid "The types of signatures that recipients are allowed to use when signing the document."
msgstr "The types of signatures that recipients are allowed to use when signing the document."
@ -8570,10 +8576,6 @@ msgstr "There are no completed documents yet. Documents that you have created or
msgid "There was an error uploading your file. Please try again."
msgstr "There was an error uploading your file. Please try again."
#: packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx
msgid "There was an issue rendering some fields, please review the fields and try again."
msgstr "There was an issue rendering some fields, please review the fields and try again."
#: packages/ui/components/document/document-global-auth-action-select.tsx
msgid "These can be overriden by setting the authentication requirements directly on each recipient in the next step. Multiple methods can be selected."
msgstr "These can be overriden by setting the authentication requirements directly on each recipient in the next step. Multiple methods can be selected."
@ -8722,6 +8724,10 @@ msgstr "This envelope could not be distributed at this time. Please try again."
msgid "This envelope could not be resent at this time. Please try again."
msgstr "This envelope could not be resent at this time. Please try again."
#: apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx
msgid "This feature is coming soon"
msgstr "This feature is coming soon"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
@ -9193,7 +9199,6 @@ msgstr "Untitled Group"
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx
#: apps/remix/app/components/dialogs/folder-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
#: packages/ui/primitives/document-flow/add-subject.tsx
msgid "Update"
@ -9230,7 +9235,6 @@ msgstr "Update organisation"
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "Update organisation member"
msgstr "Update organisation member"
@ -9251,11 +9255,9 @@ msgstr "Update profile"
msgid "Update Recipient"
msgstr "Update Recipient"
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
#: apps/remix/app/components/tables/team-members-table.tsx
#: apps/remix/app/components/tables/team-groups-table.tsx
#: apps/remix/app/components/tables/organisation-members-table.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "Update role"
msgstr "Update role"
@ -9294,10 +9296,6 @@ msgstr "Update user"
msgid "Update webhook"
msgstr "Update webhook"
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "Updated {organisationMemberName} to {roleLabel}."
msgstr "Updated {organisationMemberName} to {roleLabel}."
#: apps/remix/app/components/forms/password.tsx
msgid "Updating password..."
msgstr "Updating password..."
@ -9368,10 +9366,6 @@ msgstr "Upload Document"
msgid "Upload documents and add recipients"
msgstr "Upload documents and add recipients"
#: packages/ui/primitives/document-upload.tsx
msgid "Upload Envelope"
msgstr "Upload Envelope"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-upload-page.tsx
#: apps/remix/app/components/general/document/envelope-upload-button.tsx
@ -9668,6 +9662,10 @@ msgstr "View more"
msgid "View next document"
msgstr "View next document"
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
msgid "View Original Document"
msgstr "View Original Document"
#: apps/remix/app/components/tables/admin-organisations-table.tsx
msgid "View owner"
msgstr "View owner"
@ -9782,6 +9780,10 @@ msgstr "We are unable to update this passkey at the moment. Please try again lat
msgid "We couldn't create a Stripe customer. Please try again."
msgstr "We couldn't create a Stripe customer. Please try again."
#: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx
msgid "We couldn't promote the member to owner. Please try again."
msgstr "We couldn't promote the member to owner. Please try again."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "We couldn't update the group. Please try again."
msgstr "We couldn't update the group. Please try again."
@ -9944,7 +9946,6 @@ msgid "We encountered an unknown error while attempting to update the template.
msgstr "We encountered an unknown error while attempting to update the template. Please try again later."
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "We encountered an unknown error while attempting to update this organisation member. Please try again later."
msgstr "We encountered an unknown error while attempting to update this organisation member. Please try again later."
@ -10017,10 +10018,9 @@ msgstr "We were unable to set your public profile to public. Please try again."
msgid "We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again."
msgstr "We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again."
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx
#: apps/remix/app/components/general/direct-template/direct-template-page.tsx
#: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx
#: apps/remix/app/components/embed/embed-document-signing-page.tsx
#: apps/remix/app/components/embed/embed-direct-template-client-page.tsx
msgid "We were unable to submit this document at this time. Please try again later."
msgstr "We were unable to submit this document at this time. Please try again later."
@ -10322,7 +10322,6 @@ msgid "You are currently updating <0>{memberName}.</0>"
msgstr "You are currently updating <0>{memberName}.</0>"
#: apps/remix/app/components/dialogs/organisation-member-update-dialog.tsx
#: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx
msgid "You are currently updating <0>{organisationMemberName}.</0>"
msgstr "You are currently updating <0>{organisationMemberName}.</0>"
@ -10496,7 +10495,7 @@ msgid "You have been invited to join the following organisation"
msgstr "You have been invited to join the following organisation"
#: packages/lib/server-only/recipient/set-document-recipients.ts
#: packages/lib/server-only/recipient/delete-envelope-recipient.ts
#: packages/lib/server-only/recipient/delete-document-recipient.ts
msgid "You have been removed from a document"
msgstr "You have been removed from a document"
@ -10665,7 +10664,6 @@ msgstr "You need to be an admin to manage API tokens."
msgid "You need to be logged in as <0>{email}</0> to view this page."
msgstr "You need to be logged in as <0>{email}</0> to view this page."
#: apps/remix/app/components/general/document-signing/document-signing-auth-page.tsx
#: apps/remix/app/components/general/direct-template/direct-template-signing-auth-page.tsx
msgid "You need to be logged in to view this page."
msgstr "You need to be logged in to view this page."

File diff suppressed because it is too large Load Diff

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