diff --git a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx
index e7ffb7ea5..7fdedfd2b 100644
--- a/apps/remix/app/components/dialogs/envelope-download-dialog.tsx
+++ b/apps/remix/app/components/dialogs/envelope-download-dialog.tsx
@@ -29,9 +29,9 @@ type EnvelopeDownloadDialogProps = {
* button is hidden for them.
*
* Optional: omit it on call sites where the status can never be PENDING (DRAFT,
- * COMPLETED, REJECTED) or when a recipient token is set, since the Partial button
- * is also gated on those. Pass it from team-side call sites that can render the
- * dialog for a PENDING envelope.
+ * COMPLETED, REJECTED) or that only render v2 envelopes, such as the v2 signing
+ * page. Pass it from call sites that can render the dialog for a PENDING
+ * envelope of either version.
*/
isLegacy?: boolean;
envelopeItems?: EnvelopeItemToDownload[];
@@ -67,12 +67,10 @@ export const EnvelopeDownloadDialog = ({
// The dialog shows the original document alongside one of:
// - "Signed" (when the envelope is COMPLETED)
- // - "Partial" (when the envelope is PENDING, not legacy, and we are on the
- // team/owner side; recipients are intentionally not offered this since the
- // partial PDF carries no PKI signature and would create a leak vector for
- // half-executed contracts; legacy envelopes use a different rendering
- // pipeline that the partial-download helper does not implement)
- // - nothing (DRAFT, REJECTED, PENDING with recipient token, or legacy PENDING)
+ // - "Partial" (when the envelope is PENDING and not legacy; legacy envelopes
+ // use a different rendering pipeline that the partial-download helper does
+ // not implement)
+ // - nothing (DRAFT, REJECTED, or legacy PENDING)
const secondaryDownload = useMemo<{ version: 'signed' | 'pending'; label: string } | null>(() => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return {
@@ -81,7 +79,7 @@ export const EnvelopeDownloadDialog = ({
};
}
- if (envelopeStatus === DocumentStatus.PENDING && !token && !isLegacy) {
+ if (envelopeStatus === DocumentStatus.PENDING && !isLegacy) {
return {
version: 'pending',
label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }),
@@ -89,7 +87,7 @@ export const EnvelopeDownloadDialog = ({
}
return null;
- }, [envelopeStatus, isLegacy, token, t]);
+ }, [envelopeStatus, isLegacy, t]);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getManyByToken.useQuery(
{
diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
index 881d75238..b87368d19 100644
--- a/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
+++ b/apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
@@ -148,6 +148,13 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
);
}
+ // The envelope may complete or get rejected while this page polls, so derive
+ // the download dialog status from the live signing status.
+ const envelopeStatus = match(signingStatus)
+ .with('COMPLETED', () => DocumentStatus.COMPLETED)
+ .with('REJECTED', () => DocumentStatus.REJECTED)
+ .otherwise(() => document.status);
+
return (
<>
@@ -255,10 +262,11 @@ export default function CompletedSigningPage({ loaderData }: Route.ComponentProp
className="w-full max-w-none md:flex-1"
/>
- {isDocumentCompleted(document) && (
+ {(isDocumentCompleted(envelopeStatus) || envelopeStatus === DocumentStatus.PENDING) && (
()
},
},
include: {
- envelope: {
- include: {
- recipients: {
- select: {
- role: true,
- signingStatus: true,
- },
- },
- },
- },
+ envelope: true,
documentData: true,
},
});
diff --git a/apps/remix/server/api/files/files.helpers.ts b/apps/remix/server/api/files/files.helpers.ts
index 3b0dc1f05..ddce3653d 100644
--- a/apps/remix/server/api/files/files.helpers.ts
+++ b/apps/remix/server/api/files/files.helpers.ts
@@ -11,8 +11,7 @@ import {
DocumentStatus,
type EnvelopeType,
EnvelopeType as EnvelopeTypeEnum,
- type RecipientRole,
- type SigningStatus,
+ SigningStatus,
type TemplateType,
TemplateType as TemplateTypeEnum,
} from '@prisma/client';
@@ -55,17 +54,13 @@ type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
internalVersion: number;
- recipients: Array<{
- role: RecipientRole;
- signingStatus: SigningStatus;
- }>;
};
/**
* Options shape varies by `version`:
* - `signed` / `original`: serves stored bytes; only needs envelope `status` for cache headers.
* - `pending`: generates a fresh PDF with currently-inserted fields burned in; needs the
- * full envelope (id, status, internalVersion, recipients) plus envelopeItemId to query fields.
+ * envelope (id, status, internalVersion) plus envelopeItemId to query fields.
*/
type HandleEnvelopeItemFileRequestOptions = {
title: string;
@@ -81,6 +76,13 @@ type HandleEnvelopeItemFileRequestOptions = {
version: 'pending';
envelopeItemId: string;
envelope: EnvelopeForPendingDownload;
+
+ /**
+ * When set, only fields from recipients who have signed, plus the fields of
+ * the recipient owning this token, are burned in. Keeps recipient downloads
+ * in parity with what the signing page shows them.
+ */
+ recipientToken?: string;
}
);
@@ -165,6 +167,7 @@ const handlePendingFileRequest = async ({
envelopeItemId,
envelope,
documentData,
+ recipientToken,
context: c,
}: PendingFileRequestOptions) => {
if (envelope.status !== DocumentStatus.PENDING) {
@@ -191,6 +194,13 @@ const handlePendingFileRequest = async ({
where: {
envelopeItemId,
inserted: true,
+ ...(recipientToken
+ ? {
+ recipient: {
+ OR: [{ signingStatus: SigningStatus.SIGNED }, { token: recipientToken }],
+ },
+ }
+ : {}),
},
include: {
signature: true,
diff --git a/apps/remix/server/api/files/files.ts b/apps/remix/server/api/files/files.ts
index bbca38885..7676637a7 100644
--- a/apps/remix/server/api/files/files.ts
+++ b/apps/remix/server/api/files/files.ts
@@ -160,12 +160,6 @@ export const filesRoute = new Hono()
documentData: true,
},
},
- recipients: {
- select: {
- role: true,
- signingStatus: true,
- },
- },
},
});
@@ -289,52 +283,80 @@ export const filesRoute = new Hono()
'/token/:token/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema),
async (c) => {
- const { token, envelopeItemId, version } = c.req.valid('param');
+ const logger = c.get('logger');
- let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
- id: envelopeItemId,
- envelope: {
- recipients: {
- some: {
- token,
- },
- },
- },
- };
+ try {
+ const { token, envelopeItemId, version } = c.req.valid('param');
- if (token.startsWith('qr_')) {
- envelopeWhereQuery = {
+ let envelopeWhereQuery: Prisma.EnvelopeItemWhereUniqueInput = {
id: envelopeItemId,
envelope: {
- qrToken: token,
+ recipients: {
+ some: {
+ token,
+ },
+ },
},
};
+
+ if (token.startsWith('qr_')) {
+ envelopeWhereQuery = {
+ id: envelopeItemId,
+ envelope: {
+ qrToken: token,
+ },
+ };
+ }
+
+ const envelopeItem = await prisma.envelopeItem.findUnique({
+ where: envelopeWhereQuery,
+ include: {
+ envelope: true,
+ documentData: true,
+ },
+ });
+
+ if (!envelopeItem) {
+ return c.json({ error: 'Envelope item not found' }, 404);
+ }
+
+ if (!envelopeItem.documentData) {
+ return c.json({ error: 'Document data not found' }, 404);
+ }
+
+ const baseOptions = {
+ title: envelopeItem.title,
+ documentData: envelopeItem.documentData,
+ isDownload: true,
+ context: c,
+ } as const;
+
+ if (version === 'pending') {
+ return await handleEnvelopeItemFileRequest({
+ ...baseOptions,
+ version,
+ envelopeItemId: envelopeItem.id,
+ envelope: envelopeItem.envelope,
+ recipientToken: token,
+ });
+ }
+
+ return await handleEnvelopeItemFileRequest({
+ ...baseOptions,
+ version,
+ status: envelopeItem.envelope.status,
+ });
+ } catch (error) {
+ logger.error(error);
+
+ if (error instanceof AppError) {
+ const { status, body } = AppError.toRestAPIError(error);
+
+ return c.json({ error: body.message, code: error.code }, status);
+ }
+
+ return c.json({ error: 'Internal server error' }, 500);
}
-
- const envelopeItem = await prisma.envelopeItem.findUnique({
- where: envelopeWhereQuery,
- include: {
- envelope: true,
- documentData: true,
- },
- });
-
- if (!envelopeItem) {
- return c.json({ error: 'Envelope item not found' }, 404);
- }
-
- if (!envelopeItem.documentData) {
- return c.json({ error: 'Document data not found' }, 404);
- }
-
- return await handleEnvelopeItemFileRequest({
- title: envelopeItem.title,
- status: envelopeItem.envelope.status,
- documentData: envelopeItem.documentData,
- version,
- isDownload: true,
- context: c,
- });
},
);
diff --git a/apps/remix/server/api/files/files.types.ts b/apps/remix/server/api/files/files.types.ts
index 28cb5ded2..bec868fa6 100644
--- a/apps/remix/server/api/files/files.types.ts
+++ b/apps/remix/server/api/files/files.types.ts
@@ -44,7 +44,7 @@ export type TGetEnvelopeItemFileDownloadRequestParams = z.infer {
expect(legacyResponse.status()).toBe(400);
expect(legacyError.code).toBe('ENVELOPE_LEGACY');
});
+
+ test('allows recipients to download the partial PDF via their token', async ({ request }) => {
+ const { envelope, distributeResult } = await apiSeedPendingDocument(request, {
+ recipients: [
+ { email: 'partial-token-1@test.documenso.com', name: 'Partial Token 1' },
+ { email: 'partial-token-2@test.documenso.com', name: 'Partial Token 2' },
+ ],
+ fieldsPerRecipient: [
+ [{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
+ [
+ {
+ type: FieldType.SIGNATURE,
+ page: 1,
+ positionX: 5,
+ positionY: 15,
+ width: 15,
+ height: 5,
+ },
+ ],
+ ],
+ });
+
+ const [recipientOne, recipientTwo] = distributeResult.recipients;
+ const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
+ const envelopeItem = envelope.envelopeItems[0];
+ const recipientOneField = envelope.fields.find((field) => field.recipientId === recipientOne.id);
+
+ if (!recipientOneField) {
+ throw new Error('Expected signature field not found');
+ }
+
+ const tokenDownloadUrl = (token: string) =>
+ `${WEBAPP_BASE_URL}/api/files/token/${token}/envelopeItem/${envelopeItem.id}/download/pending`;
+
+ // Recipient one inserts their field without completing the document.
+ await trpcMutation(request, 'envelope.field.sign', {
+ token: recipientOne.token,
+ fieldId: recipientOneField.id,
+ fieldValue: {
+ type: FieldType.SIGNATURE,
+ value: 'Signature',
+ },
+ });
+
+ const recipientOneResponse = await request.get(tokenDownloadUrl(recipientOne.token));
+
+ expect(recipientOneResponse.status()).toBe(200);
+ expect(recipientOneResponse.headers()['content-type']).toContain('application/pdf');
+ expect(recipientOneResponse.headers()['cache-control']).toBe('no-store, private');
+ expect(recipientOneResponse.headers()['content-disposition']).toContain('_pending.pdf');
+ await getPdfBytes(recipientOneResponse);
+
+ // Recipient two must not see recipient one's in-progress field. The ETag is
+ // derived from the included fields, so the two downloads must differ.
+ const recipientTwoResponse = await request.get(tokenDownloadUrl(recipientTwo.token));
+
+ expect(recipientTwoResponse.status()).toBe(200);
+ await getPdfBytes(recipientTwoResponse);
+
+ expect(recipientOneResponse.headers().etag).not.toBe(recipientTwoResponse.headers().etag);
+
+ // Once recipient one completes, their field becomes visible to recipient two.
+ await trpcMutation(request, 'recipient.completeDocumentWithToken', {
+ token: recipientOne.token,
+ documentId,
+ });
+
+ await expect(async () => {
+ const dbRecipient = await prisma.recipient.findFirstOrThrow({
+ where: {
+ id: recipientOne.id,
+ },
+ });
+
+ expect(dbRecipient.signingStatus).toBe(SigningStatus.SIGNED);
+ }).toPass();
+
+ const afterCompletionResponse = await request.get(tokenDownloadUrl(recipientTwo.token));
+
+ expect(afterCompletionResponse.status()).toBe(200);
+ expect(afterCompletionResponse.headers().etag).toBe(recipientOneResponse.headers().etag);
+ });
+
+ test('rejects a recipient token pending download once the envelope is completed', async ({ request }) => {
+ const { envelope, distributeResult } = await apiSeedPendingDocument(request);
+
+ const [recipient] = distributeResult.recipients;
+ const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
+ const recipientField = envelope.fields.find((field) => field.recipientId === recipient.id);
+
+ if (!recipientField) {
+ throw new Error('Expected signature field not found');
+ }
+
+ await signAndCompleteRecipient({
+ request,
+ token: recipient.token,
+ documentId,
+ fieldId: recipientField.id,
+ });
+
+ await expect(async () => {
+ const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
+ where: {
+ id: envelope.id,
+ },
+ });
+
+ expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
+ }).toPass({ timeout: 15_000 });
+
+ const completedResponse = await request.get(
+ `${WEBAPP_BASE_URL}/api/files/token/${recipient.token}/envelopeItem/${envelope.envelopeItems[0].id}/download/pending`,
+ );
+ const completedError = await completedResponse.json();
+
+ expect(completedResponse.status()).toBe(400);
+ expect(completedError.code).toBe('ENVELOPE_COMPLETED');
+ });
});
diff --git a/packages/lib/client-only/download-pdf.ts b/packages/lib/client-only/download-pdf.ts
index 3bde40884..5e7a11d3c 100644
--- a/packages/lib/client-only/download-pdf.ts
+++ b/packages/lib/client-only/download-pdf.ts
@@ -15,8 +15,7 @@ type DownloadPDFProps = {
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
* 'pending': Downloads the original document with currently-inserted fields burned in.
- * Only valid while the envelope is in PENDING status. Not supported via
- * recipient token.
+ * Only valid while the envelope is in PENDING status.
*/
version?: DocumentVersion;
};
diff --git a/packages/lib/utils/envelope-download.ts b/packages/lib/utils/envelope-download.ts
index c1e3a1e59..0ab5558b9 100644
--- a/packages/lib/utils/envelope-download.ts
+++ b/packages/lib/utils/envelope-download.ts
@@ -4,8 +4,8 @@ import type { EnvelopeItem } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
/**
- * `pending` is only supported when there is no recipient token (team/owner-side downloads
- * via the session-authed file route). The recipient-token route does not accept `pending`.
+ * `pending` downloads a PDF with the currently-inserted fields burned in. Supported
+ * for both session-authed and recipient-token downloads while the envelope is PENDING.
*/
export type EnvelopeItemPdfUrlOptions =
| {