mirror of
https://github.com/documenso/documenso.git
synced 2026-08-15 11:01:47 +10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6ad94a58f |
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<RecipientBranding branding={branding} cspNonce={cspNonce} />
|
||||
@@ -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) && (
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={document.envelopeId}
|
||||
envelopeStatus={document.status}
|
||||
envelopeStatus={envelopeStatus}
|
||||
isLegacy={document.internalVersion === 1}
|
||||
envelopeItems={document.envelopeItems}
|
||||
token={recipient?.token}
|
||||
trigger={
|
||||
|
||||
@@ -83,16 +83,7 @@ export const downloadRoute = new Hono<HonoEnv>()
|
||||
},
|
||||
},
|
||||
include: {
|
||||
envelope: {
|
||||
include: {
|
||||
recipients: {
|
||||
select: {
|
||||
role: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
envelope: true,
|
||||
documentData: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -160,12 +160,6 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
recipients: {
|
||||
select: {
|
||||
role: true,
|
||||
signingStatus: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -289,52 +283,80 @@ export const filesRoute = new Hono<HonoEnv>()
|
||||
'/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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<typeof ZGetEnvel
|
||||
export const ZGetEnvelopeItemFileTokenDownloadRequestParamsSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
envelopeItemId: z.string().min(1),
|
||||
version: z.enum(['signed', 'original']).default('signed'),
|
||||
version: z.enum(['signed', 'original', 'pending']).default('signed'),
|
||||
});
|
||||
|
||||
export type TGetEnvelopeItemFileTokenDownloadRequestParams = z.infer<
|
||||
|
||||
@@ -241,4 +241,123 @@ test.describe('API V2 partial signed PDF downloads', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 =
|
||||
| {
|
||||
|
||||
Reference in New Issue
Block a user