feat: allow recipients to view completed PDF via QR share link

Add allowPublicCompletedDocumentAccess toggle at org/team level
(team inherits from org via null). Recipients see a "View completed
PDF" button on the signing completion page that links to /share/qr_*.

- DB migration adding toggle to OrganisationGlobalSettings and TeamGlobalSettings
- Settings UI for org and team document preferences
- Rate limiting on QR share view and file download endpoints
- Structured error responses with support codes in share route ErrorBoundary
- Exponential backoff retry when qrToken not yet available post-completion
- QR-authenticated viewers restricted to signed PDF only (no original)
- E2E tests covering happy path, not-yet-completed, invalid token, and toggle revocation
This commit is contained in:
ephraimduncan
2026-03-04 14:45:16 +00:00
parent 454f73f2a9
commit acb5a885c2
20 changed files with 1011 additions and 67 deletions
@@ -0,0 +1,193 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { DocumentStatus, FieldType } from '@prisma/client';
import { prisma } from '@documenso/prisma';
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { signSignaturePad } from '../fixtures/signature';
const completeSigningForRecipient = async ({
page,
token,
fields,
}: {
page: Page;
token: string;
fields: { id: number; type: FieldType }[];
}) => {
const signUrl = `/sign/${token}`;
await page.goto(signUrl);
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
await signSignaturePad(page);
for (const field of fields) {
await page.locator(`#field-${field.id}`).getByRole('button').click();
if (field.type === FieldType.TEXT) {
await page.locator('#custom-text').fill('TEXT');
await page.getByRole('button', { name: 'Save' }).click();
}
await expect(page.locator(`#field-${field.id}`)).toHaveAttribute('data-inserted', 'true');
}
await page.getByRole('button', { name: 'Complete' }).click();
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(`${signUrl}/complete`);
};
test('[DOCUMENT_AUTH]: recipient outside team can open completed PDF via QR share route', async ({
page,
}) => {
const { user: owner, team } = await seedUser();
const { user: outsideRecipientA } = await seedUser();
const { user: outsideRecipientB } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner,
teamId: team.id,
recipients: [outsideRecipientA, outsideRecipientB],
fields: [FieldType.SIGNATURE],
});
const firstRecipient = recipients[0];
const secondRecipient = recipients[1];
await completeSigningForRecipient({
page,
token: firstRecipient.token,
fields: firstRecipient.fields,
});
await expect(page.getByRole('link', { name: 'View completed PDF' })).not.toBeVisible();
await completeSigningForRecipient({
page,
token: secondRecipient.token,
fields: secondRecipient.fields,
});
await expect(async () => {
await page.reload();
await expect(page.getByRole('link', { name: 'View completed PDF' })).toBeVisible();
}).toPass({
timeout: 30000,
intervals: [1000, 2000, 3000],
});
await page.getByRole('link', { name: 'View completed PDF' }).click();
await expect(page).toHaveURL(/\/share\/qr_/);
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
test('[DOCUMENT_AUTH]: recipient cannot access final view action before full completion', async ({
page,
}) => {
const { user: owner, team } = await seedUser();
const { user: outsideRecipientA } = await seedUser();
const { user: outsideRecipientB } = await seedUser();
const { recipients } = await seedPendingDocumentWithFullFields({
owner,
teamId: team.id,
recipients: [outsideRecipientA, outsideRecipientB],
fields: [FieldType.SIGNATURE],
});
const firstRecipient = recipients[0];
await completeSigningForRecipient({
page,
token: firstRecipient.token,
fields: firstRecipient.fields,
});
await expect(page.getByText('Waiting for others to sign')).toBeVisible();
await expect(page.getByRole('link', { name: 'View completed PDF' })).not.toBeVisible();
});
test('[DOCUMENT_AUTH]: invalid QR share token is denied', async ({ page }) => {
await page.goto('/share/qr_invalid_token');
await expect(page.getByRole('heading', { name: 'Unable to open this document' })).toBeVisible();
await expect(page.getByText('Support code:')).toBeVisible();
});
test('[DOCUMENT_AUTH]: disabling public completed-document access revokes QR share access immediately', async ({
page,
}) => {
const { user: owner, team } = await seedUser();
const { user: outsideRecipientA } = await seedUser();
const { user: outsideRecipientB } = await seedUser();
const { document, recipients } = await seedPendingDocumentWithFullFields({
owner,
teamId: team.id,
recipients: [outsideRecipientA, outsideRecipientB],
fields: [FieldType.SIGNATURE],
});
await completeSigningForRecipient({
page,
token: recipients[0].token,
fields: recipients[0].fields,
});
await completeSigningForRecipient({
page,
token: recipients[1].token,
fields: recipients[1].fields,
});
await expect(async () => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
id: document.id,
},
select: {
id: true,
status: true,
qrToken: true,
},
});
expect(envelope.status).toBe(DocumentStatus.COMPLETED);
expect(envelope.qrToken).toBeTruthy();
}).toPass();
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
where: {
id: document.id,
},
select: {
qrToken: true,
},
});
const teamSettings = await prisma.teamGlobalSettings.findFirstOrThrow({
where: {
team: {
id: team.id,
},
},
});
await prisma.teamGlobalSettings.update({
where: {
id: teamSettings.id,
},
data: {
allowPublicCompletedDocumentAccess: false,
},
});
await page.goto(`/share/${completedEnvelope.qrToken}`);
await expect(page.getByRole('heading', { name: 'Unable to open this document' })).toBeVisible();
await expect(
page.getByText('Public completed-document access is currently disabled.'),
).toBeVisible();
});
@@ -1,5 +1,6 @@
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { mapSecondaryIdToDocumentId } from '../../utils/envelope';
@@ -10,25 +11,42 @@ export type GetDocumentByAccessTokenOptions = {
export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTokenOptions) => {
if (!token) {
throw new Error('Missing token');
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Missing QR access token',
statusCode: 401,
});
}
const result = await prisma.envelope.findFirstOrThrow({
const result = await prisma.envelope.findFirst({
where: {
type: EnvelopeType.DOCUMENT,
status: DocumentStatus.COMPLETED,
qrToken: token,
},
// Do not provide extra information that is not needed.
select: {
id: true,
secondaryId: true,
status: true,
internalVersion: true,
title: true,
completedAt: true,
team: {
select: {
url: true,
organisation: {
select: {
organisationGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
teamGlobalSettings: {
select: {
allowPublicCompletedDocumentAccess: true,
},
},
},
},
envelopeItems: {
@@ -56,6 +74,32 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
},
});
if (!result) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'QR token not found',
statusCode: 404,
});
}
if (result.status !== DocumentStatus.COMPLETED) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Document is not fully completed',
statusCode: 409,
});
}
const allowPublicCompletedDocumentAccess =
result.team?.teamGlobalSettings?.allowPublicCompletedDocumentAccess ??
result.team?.organisation.organisationGlobalSettings.allowPublicCompletedDocumentAccess ??
true;
if (!allowPublicCompletedDocumentAccess) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'Public completed-document access is disabled for this document',
statusCode: 403,
});
}
const firstDocumentData = result.envelopeItems[0].documentData;
if (!firstDocumentData) {
@@ -69,6 +113,6 @@ export const getDocumentByAccessToken = async ({ token }: GetDocumentByAccessTok
completedAt: result.completedAt,
envelopeItems: result.envelopeItems,
recipientCount: result._count.recipients,
documentTeamUrl: result.team.url,
documentTeamUrl: result.team?.url ?? '',
};
};
@@ -97,3 +97,10 @@ export const fileUploadRateLimit = createRateLimit({
max: 20,
window: '1m',
});
export const qrShareViewRateLimit = createRateLimit({
action: 'app.qr-share-view',
max: 20,
globalMax: 120,
window: '1m',
});
+1
View File
@@ -122,6 +122,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
includeSenderDetails: true,
includeSigningCertificate: true,
allowPublicCompletedDocumentAccess: true,
includeAuditLog: false,
typedSignatureEnabled: true,
+1
View File
@@ -188,6 +188,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
includeSenderDetails: null,
includeSigningCertificate: null,
allowPublicCompletedDocumentAccess: null,
includeAuditLog: null,
typedSignatureEnabled: null,
@@ -0,0 +1,5 @@
ALTER TABLE "OrganisationGlobalSettings"
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "TeamGlobalSettings"
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN;
+2
View File
@@ -823,6 +823,7 @@ model OrganisationGlobalSettings {
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
allowPublicCompletedDocumentAccess Boolean @default(true)
includeAuditLog Boolean @default(false)
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
@@ -865,6 +866,7 @@ model TeamGlobalSettings {
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
allowPublicCompletedDocumentAccess Boolean?
includeAuditLog Boolean?
typedSignatureEnabled Boolean?
@@ -32,6 +32,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -143,6 +144,7 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -20,6 +20,7 @@ export const ZUpdateOrganisationSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.optional(),
includeSenderDetails: z.boolean().optional(),
includeSigningCertificate: z.boolean().optional(),
allowPublicCompletedDocumentAccess: z.boolean().optional(),
includeAuditLog: z.boolean().optional(),
typedSignatureEnabled: z.boolean().optional(),
uploadSignatureEnabled: z.boolean().optional(),
@@ -35,6 +35,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -150,6 +151,7 @@ export const updateTeamSettingsRoute = authenticatedProcedure
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
allowPublicCompletedDocumentAccess,
includeAuditLog,
typedSignatureEnabled,
uploadSignatureEnabled,
@@ -24,6 +24,7 @@ export const ZUpdateTeamSettingsRequestSchema = z.object({
documentDateFormat: ZDocumentMetaDateFormatSchema.nullish(),
includeSenderDetails: z.boolean().nullish(),
includeSigningCertificate: z.boolean().nullish(),
allowPublicCompletedDocumentAccess: z.boolean().nullish(),
includeAuditLog: z.boolean().nullish(),
typedSignatureEnabled: z.boolean().nullish(),
uploadSignatureEnabled: z.boolean().nullish(),