mirror of
https://github.com/documenso/documenso.git
synced 2026-07-26 18:04:55 +10:00
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:
@@ -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',
|
||||
});
|
||||
|
||||
@@ -122,6 +122,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
|
||||
|
||||
includeSenderDetails: true,
|
||||
includeSigningCertificate: true,
|
||||
allowPublicCompletedDocumentAccess: true,
|
||||
includeAuditLog: false,
|
||||
|
||||
typedSignatureEnabled: true,
|
||||
|
||||
@@ -188,6 +188,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
|
||||
|
||||
includeSenderDetails: null,
|
||||
includeSigningCertificate: null,
|
||||
allowPublicCompletedDocumentAccess: null,
|
||||
includeAuditLog: null,
|
||||
|
||||
typedSignatureEnabled: null,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "OrganisationGlobalSettings"
|
||||
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
ALTER TABLE "TeamGlobalSettings"
|
||||
ADD COLUMN "allowPublicCompletedDocumentAccess" BOOLEAN;
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user