diff --git a/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx b/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx index e472bb5c9..0ab921a59 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx @@ -1,3 +1,4 @@ +import { toSafeHref } from '@documenso/lib/utils/is-http-url'; import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Popover, PopoverContent, PopoverTrigger } from '@documenso/ui/primitives/popover'; @@ -53,7 +54,7 @@ export const DocumentSigningAttachmentsPopover = ({ {attachments?.data.map((attachment) => ( ; @@ -156,7 +157,7 @@ export const DocumentAttachmentsPopover = ({

{attachment.label}

; @@ -117,7 +118,7 @@ export const EmbeddedEditorAttachmentPopover = ({

{attachment.label}

(null); + /** + * Whether the field was automatically selected on creation (drag-drop or marquee). + * + * We purposefully supress the floating toolbar for newly created fields. + */ + const [isAutoSelectedField, setIsAutoSelectedField] = useState(false); + const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer( ({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), pageData, @@ -237,10 +244,26 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR fieldGroup.off('transformend'); fieldGroup.off('dragend'); - // Set up field selection. - fieldGroup.on('click', () => { + // Set up field selection. Shift + click toggles this field in/out of the current + // multi-selection, so fields can be added to a group by clicking them -- + // complementing marquee drag-selection. A plain click (no modifier) selects just + // this field. + fieldGroup.on('click', (event) => { removePendingField(); - setSelectedFields([fieldGroup]); + + const isMultiSelectModifier = event.evt.shiftKey; + + if (isMultiSelectModifier) { + const currentNodes = interactiveTransformer.current?.nodes() ?? []; + const isAlreadySelected = currentNodes.includes(fieldGroup); + + setSelectedFields( + isAlreadySelected ? currentNodes.filter((node) => node !== fieldGroup) : [...currentNodes, fieldGroup], + ); + } else { + setSelectedFields([fieldGroup]); + } + pageLayer.current?.batchDraw(); }); @@ -445,43 +468,18 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR } }); - // Clicks should select/deselect shapes + // Clicking empty stage area clears the selection. Field clicks -- including + // Shift+click multi-select -- are handled by each field group's own click + // handler in `unsafeRenderFieldOnLayer`. currentStage.on('click tap', (e) => { - // if we are selecting with rect, do nothing + // If we are selecting with the marquee rectangle, do nothing. if (selectionRectangle.visible() && selectionRectangle.width() > 0 && selectionRectangle.height() > 0) { return; } - // If empty area clicked, remove all selections + // If empty area clicked, remove all selections. if (e.target === stage.current) { setSelectedFields([]); - return; - } - - // Do nothing if field not clicked, or if field is not editable - if (!e.target.hasName('field-group') || e.target.draggable() === false) { - return; - } - - // do we pressed shift or ctrl? - const metaPressed = e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey; - const isSelected = transformer.nodes().indexOf(e.target) >= 0; - - if (!metaPressed && !isSelected) { - // if no key pressed and the node is not selected - // select just one - setSelectedFields([e.target]); - } else if (metaPressed && isSelected) { - // if we pressed keys and node was selected - // we need to remove it from selection: - const nodes = transformer.nodes().slice(); // use slice to have new copy of array - // remove node from array - nodes.splice(nodes.indexOf(e.target), 1); - setSelectedFields(nodes); - } else if (metaPressed && !isSelected) { - // add the node into selection - const nodes = transformer.nodes().concat([e.target]); - setSelectedFields(nodes); } }); @@ -521,13 +519,48 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR setSelectedFields(liveSelectedFieldGroups); } + // Mirror the editor's single selected field onto the canvas (Konva) selection. + // + // `addField` already marks a newly created field as the selected field, so this + // makes a field placed via the palette (drag-drop) or marquee creation show its + // resize handles immediately -- no second click needed. It also clears the canvas + // selection when the selected field is cleared (e.g. when the author starts + // placing another field), so the floating action toolbar can't intercept the next + // placement click. Runs after the render loop above so the field's group exists. + const selectedFormId = editorFields.selectedField?.formId ?? null; + const isSingleCanvasSelection = selectedKonvaFieldGroups.length === 1; + + if (selectedFormId && localPageFields.some((field) => field.formId === selectedFormId)) { + const isAlreadySelected = isSingleCanvasSelection && selectedKonvaFieldGroups[0].id() === selectedFormId; + + if (!isAlreadySelected) { + const fieldGroupToSelect = pageLayer.current.findOne(`#${selectedFormId}`); + + if (fieldGroupToSelect instanceof Konva.Group) { + setSelectedFields([fieldGroupToSelect], { isAutoSelect: true }); + } + } + } else if (selectedFormId === null && isSingleCanvasSelection) { + setSelectedFields([]); + } + // Rerender the transformer interactiveTransformer.current?.forceUpdate(); pageLayer.current.batchDraw(); - }, [localPageFields, selectedKonvaFieldGroups, overlappingFieldFormIds, isFieldChanging]); + }, [ + localPageFields, + selectedKonvaFieldGroups, + overlappingFieldFormIds, + isFieldChanging, + editorFields.selectedField?.formId, + ]); + + const setSelectedFields = (nodes: Konva.Node[], options?: { isAutoSelect?: boolean }) => { + // Any explicit (user-driven) selection shows the action toolbar; only auto-selection + // on field creation suppresses it. + setIsAutoSelectedField(Boolean(options?.isAutoSelect)); - const setSelectedFields = (nodes: Konva.Node[]) => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions const fieldGroups = nodes.filter( (node) => node.hasName('field-group') && Boolean(node.getStage()) && Boolean(node.getParent()), @@ -663,25 +696,30 @@ export const EnvelopeEditorFieldsPageRenderer = ({ pageData }: { pageData: PageR return ( <> - {selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && ( - field.id())} - style={{ - position: 'absolute', - top: interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px', - left: interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px', - transform: 'translateX(-50%)', - gap: '8px', - pointerEvents: 'auto', - zIndex: 50, - }} - /> - )} + {selectedKonvaFieldGroups.length > 0 && + interactiveTransformer.current && + !isFieldChanging && + !isAutoSelectedField && ( + field.id())} + style={{ + position: 'absolute', + top: + interactiveTransformer.current.y() + interactiveTransformer.current.getClientRect().height + 5 + 'px', + left: + interactiveTransformer.current.x() + interactiveTransformer.current.getClientRect().width / 2 + 'px', + transform: 'translateX(-50%)', + gap: '8px', + pointerEvents: 'auto', + zIndex: 50, + }} + /> + )} {pendingFieldCreation && (
-
- + {canConfigureBranding ? ( +
+ - {cssWarnings.length > 0 && ( - + {cssWarnings.length > 0 && ( + + + CSS rules were dropped during sanitisation + + + +
    + {cssWarnings.map((warning, index) => ( +
  • + {warning.detail} + {warning.line !== undefined && ( + + {' '} + (line {warning.line}) + + )} +
  • + ))} +
+
+
+ )} +
+ ) : ( + +
- CSS rules were dropped during sanitisation + Branding Preferences - -
    - {cssWarnings.map((warning, index) => ( -
  • - {warning.detail} - {warning.line !== undefined && ( - - {' '} - (line {warning.line}) - - )} -
  • - ))} -
+ + Currently branding can only be configured for Teams and above plans. - - )} -
+
+ + {canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && ( + + )} + + )}
); } diff --git a/apps/remix/package.json b/apps/remix/package.json index 2f4d6fea0..22a92ee34 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -106,5 +106,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.13.0" + "version": "2.14.0" } diff --git a/apps/remix/server/api/download/download.ts b/apps/remix/server/api/download/download.ts index 796a68653..6682c327c 100644 --- a/apps/remix/server/api/download/download.ts +++ b/apps/remix/server/api/download/download.ts @@ -146,7 +146,7 @@ export const downloadRoute = new Hono() * Requires API key authentication via Authorization header. */ .get( - '/envelope/:envelopeId/audit-log/pdf', + '/envelope/:envelopeId/audit-log/download', sValidator('param', ZDownloadEnvelopeAuditLogPdfRequestParamsSchema), async (c) => { const logger = c.get('logger'); @@ -220,7 +220,7 @@ export const downloadRoute = new Hono() * Requires API key authentication via Authorization header. */ .get( - '/envelope/:envelopeId/certificate/pdf', + '/envelope/:envelopeId/certificate/download', sValidator('param', ZDownloadEnvelopeCertificatePdfRequestParamsSchema), async (c) => { const logger = c.get('logger'); diff --git a/package-lock.json b/package-lock.json index b43306e42..cb4e523f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.13.0", + "version": "2.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.13.0", + "version": "2.14.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -406,7 +406,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.13.0", + "version": "2.14.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index e32496f6b..d0ffcb2b5 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.13.0", + "version": "2.14.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", diff --git a/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-cert-audit-log.spec.ts b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-cert-audit-log.spec.ts new file mode 100644 index 000000000..17216ee67 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/unauthorized-api-access/api-access-envelope-cert-audit-log.spec.ts @@ -0,0 +1,284 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedCompletedDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { type APIRequestContext, expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +/** + * Create an API token directly, bypassing the role check in `createApiToken`. + * + * This simulates a token that was minted while the user had permission, and which + * survives a later downgrade to a lower team role (e.g. MEMBER). Such a token must + * still respect document visibility at request time. + */ +const seedApiTokenForUser = async ({ + userId, + teamId, + tokenName, +}: { + userId: number; + teamId: number; + tokenName: string; +}) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { + name: tokenName, + token: hashString(token), + expires: null, + userId, + teamId, + }, + }); + + return { token }; +}; + +test.describe.configure({ + mode: 'parallel', +}); + +const downloadAuditLogPdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => { + return request.get(`${API_BASE_URL}/envelope/${envelopeId}/audit-log/download`, { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }); +}; + +const downloadCertificatePdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => { + return request.get(`${API_BASE_URL}/envelope/${envelopeId}/certificate/download`, { + headers: authToken ? { Authorization: `Bearer ${authToken}` } : {}, + }); +}; + +test.describe('Envelope certificate / audit log PDF download API V2 - access control', () => { + let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'userA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'userB', + expiresIn: null, + })); + }); + + test('should reject audit log download without an API token', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadAuditLogPdf(request, document.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject certificate download without an API token', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadCertificatePdf(request, document.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject audit log download from a user in a different team', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadAuditLogPdf(request, document.id, tokenB); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should reject certificate download from a user in a different team', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + const res = await downloadCertificatePdf(request, document.id, tokenB); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should reject a disabled user downloading the audit log', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + await prisma.user.update({ + where: { id: userA.id }, + data: { disabled: true }, + }); + + const res = await downloadAuditLogPdf(request, document.id, tokenA); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject a disabled user downloading the certificate', async ({ request }) => { + const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']); + + await prisma.user.update({ + where: { id: userA.id }, + data: { disabled: true }, + }); + + const res = await downloadCertificatePdf(request, document.id, tokenA); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should return 404 for a non-existent envelope id', async ({ request }) => { + const auditLogRes = await downloadAuditLogPdf(request, 'envelope_doesnotexist', tokenA); + expect(auditLogRes.status()).toBe(404); + + const certificateRes = await downloadCertificatePdf(request, 'envelope_doesnotexist', tokenA); + expect(certificateRes.status()).toBe(404); + }); +}); + +test.describe('Envelope certificate / audit log PDF download API V2 - document visibility', () => { + test.describe.configure({ + mode: 'parallel', + }); + + test('should hide an ADMIN-only document from a downgraded member (audit log)', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ + userId: member.id, + teamId: team.id, + tokenName: 'member-audit-log-token', + }); + + // ADMIN-visibility document owned by the team owner - a member must not see it. + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const res = await downloadAuditLogPdf(request, document.id, memberToken); + + // Visibility failure surfaces as not-found, matching the canonical access checks. + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide an ADMIN-only document from a downgraded member (certificate)', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ + userId: member.id, + teamId: team.id, + tokenName: 'member-certificate-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const res = await downloadCertificatePdf(request, document.id, memberToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide a MANAGER_AND_ABOVE document from a downgraded member (audit log)', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ + userId: member.id, + teamId: team.id, + tokenName: 'member-manager-vis-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE }, + }); + + const res = await downloadAuditLogPdf(request, document.id, memberToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide a MANAGER_AND_ABOVE document from a downgraded member (certificate)', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ + userId: member.id, + teamId: team.id, + tokenName: 'member-manager-vis-cert-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE }, + }); + + const res = await downloadCertificatePdf(request, document.id, memberToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should hide an ADMIN-only document from a downgraded manager (certificate)', async ({ request }) => { + const { team, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + const { token: managerToken } = await seedApiTokenForUser({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-admin-vis-cert-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const res = await downloadCertificatePdf(request, document.id, managerToken); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should allow a member to download an EVERYONE-visibility document (audit log)', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ + userId: member.id, + teamId: team.id, + tokenName: 'member-everyone-vis-token', + }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.EVERYONE }, + }); + + const res = await downloadAuditLogPdf(request, document.id, memberToken); + + expect(res.ok()).toBeTruthy(); + expect(res.status()).toBe(200); + expect(res.headers()['content-type']).toContain('application/pdf'); + }); +}); diff --git a/packages/app-tests/e2e/documents/document-visibility-access.spec.ts b/packages/app-tests/e2e/documents/document-visibility-access.spec.ts new file mode 100644 index 000000000..0ba95937d --- /dev/null +++ b/packages/app-tests/e2e/documents/document-visibility-access.spec.ts @@ -0,0 +1,89 @@ +import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document'; +import { deleteDocument } from '@documenso/lib/server-only/document/delete-document'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import { expect, test } from '@playwright/test'; + +const requestMetadata = { + auth: null, + requestMetadata: {}, + source: 'app' as const, +}; + +test.describe.configure({ mode: 'parallel' }); + +const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => { + try { + await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput })); + + return true; + } catch { + return false; + } +}; + +test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => { + const { user: owner, team } = await seedUser(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + status: DocumentStatus.DRAFT, + }, + }); + + // The member cannot read an ADMIN-visibility document, so they must not be + // able to delete it either. + expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false); + + await expect( + deleteDocument({ + id: { type: 'envelopeId', id: envelope.id }, + userId: member.id, + teamId: team.id, + requestMetadata, + }), + ).rejects.toThrow(); + + const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } }); + expect(stillExists).not.toBeNull(); +}); + +test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => { + const { user: owner, team } = await seedUser(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { + visibility: DocumentVisibility.ADMIN, + status: DocumentStatus.PENDING, + }, + }); + + // A manager outranks a member but still cannot read an ADMIN-visibility + // document, so cancellation must be blocked despite the sufficient role. + expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false); + + await expect( + cancelDocument({ + id: { type: 'envelopeId', id: envelope.id }, + userId: manager.id, + teamId: team.id, + reason: 'test-cancel', + requestMetadata, + }), + ).rejects.toThrow(); + + const after = await prisma.envelope.findUnique({ where: { id: envelope.id } }); + expect(after?.status).toBe(DocumentStatus.PENDING); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts new file mode 100644 index 000000000..c3228a057 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/attachment-url-validation.spec.ts @@ -0,0 +1,70 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +/** + * Attachment URLs are rendered as link hrefs, so they must be restricted to + * http(s). The API must reject any other scheme. + */ +const NON_HTTP_URLS = [ + 'javascript:alert(document.cookie)', + 'data:text/html,', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', +]; + +test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id); + + for (const url of NON_HTTP_URLS) { + const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } }, + }); + + expect(res.ok(), `expected ${url} to be rejected`).toBe(false); + } + + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(0); +}); + +test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id); + + const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } }, + }); + + expect(res.ok()).toBe(true); + + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(1); + expect(attachments[0].data).toBe('https://example.com/file.pdf'); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts new file mode 100644 index 000000000..f47a76c1f --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/attachment-visibility-access.spec.ts @@ -0,0 +1,121 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedBlankDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { type APIRequestContext, expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => { + const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return res.ok(); +}; + +/** + * Attachment create/update/delete/list must enforce document visibility, not + * just team membership. A member whose visibility tier excludes a restricted + * envelope must not be able to read or mutate its attachments. + */ +test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } }, + }); + + expect(createRes.ok()).toBe(false); + + // No attachment should have been created. + const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } }); + expect(attachments).toHaveLength(0); +}); + +test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + // The owner (who can see the document) creates the attachment. + const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } }, + }); + expect(createRes.ok()).toBe(true); + const attachment = await createRes.json(); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, { + headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' }, + data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } }, + }); + + expect(updateRes.ok()).toBe(false); + + const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } }); + expect(persisted?.label).toBe('original'); +}); + +test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id }); + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const envelope = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + await request.post(`${API_BASE_URL}/envelope/attachment/create`, { + headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' }, + data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } }, + }); + + expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false); + + const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(findRes.ok()).toBe(false); + + const body = findRes.ok() ? await findRes.json() : null; + const attachments = body?.data ?? []; + expect(attachments).toHaveLength(0); +}); diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts index b4130a358..0a4c3ab73 100644 --- a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts @@ -20,7 +20,7 @@ import { type TEnvelopeEditorSurface, } from '../fixtures/envelope-editor'; import { expectToastTextToBeVisible } from '../fixtures/generic'; -import { getKonvaElementCountForPage } from '../fixtures/konva'; +import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva'; type TFieldFlowResult = { externalId: string; @@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str if (!surface.isEmbedded) { await expectToastTextToBeVisible(surface.root, 'Envelope updated'); + await surface.root.getByTestId('toast-close').click(); } }; @@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number await canvas.click({ position, force: true }); }; +/** + * Shift+click a field on the canvas to toggle it in/out of the current multi-selection. + */ +const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => { + const canvas = root.locator('.konva-container canvas').first(); + await expect(canvas).toBeVisible(); + await root.waitForTimeout(300); + // Use force:true to bypass any floating action toolbar buttons that may intercept clicks. + await canvas.click({ position, modifiers: ['Shift'], force: true }); +}; + const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise => { const externalId = `e2e-fields-${nanoid()}`; @@ -760,9 +772,106 @@ const assertChangeFieldTypePersistedInDatabase = async ({ expect(actualMetaTypes).toEqual(['date', 'date']); }; +// --- Shift+click multi-select flow --- + +type TShiftClickFlowResult = { + externalId: string; +}; + +const SHIFT_CLICK_FIELD_POSITIONS = { + signature: { x: 150, y: 120 }, + text: { x: 150, y: 260 }, + name: { x: 150, y: 400 }, +}; + +const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise => { + const externalId = `e2e-shift-click-${nanoid()}`; + const root = surface.root; + + if (surface.isEmbedded && !surface.envelopeId) { + await addEnvelopeItemPdf(root, 'embedded-fields.pdf'); + } + + await updateExternalId(surface, externalId); + await setupRecipientsForFieldPlacement(surface); + + await clickEnvelopeEditorStep(root, 'addFields'); + await expect(root.locator('.konva-container canvas').first()).toBeVisible(); + + // Place three fields, spaced far enough apart that their action toolbars don't + // overlap a neighbouring field's click target. + await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature); + await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text); + await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name); + expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3); + + // A plain click selects exactly one field. + await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1); + + // Shift+click a second field ADDS it to the selection (the new behaviour). + await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2); + + // Shift+click an already-selected field REMOVES it from the selection. + await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1); + + // Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded. + await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature); + await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2); + + // Delete the two selected fields via the floating action toolbar. Only the + // un-selected Name field should remain -- proving the multi-selection contained + // exactly the two Shift-clicked fields. + await expect(root.locator('button[title="Remove"]')).toBeVisible(); + await root.locator('button[title="Remove"]').click(); + expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1); + + // Navigate away and back to verify persistence. + await clickEnvelopeEditorStep(root, 'upload'); + await clickEnvelopeEditorStep(root, 'addFields'); + expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1); + + return { externalId }; +}; + +const assertShiftClickMultiSelectPersistedInDatabase = async ({ + surface, + externalId, +}: { + surface: TEnvelopeEditorSurface; + externalId: string; +}) => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + externalId, + userId: surface.userId, + teamId: surface.teamId, + type: surface.envelopeType, + }, + orderBy: { createdAt: 'desc' }, + include: { fields: true }, + }); + + // Signature + Text were multi-selected via Shift+click and deleted; only Name remains. + expect(envelope.fields).toHaveLength(1); + expect(envelope.fields[0].type).toBe(FieldType.NAME); +}; + // --- Test describe blocks --- test.describe('document editor', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const result = await runShiftClickMultiSelectFlow(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runAddAndPersistSignatureTextFields(surface); @@ -815,6 +924,16 @@ test.describe('document editor', () => { }); test.describe('template editor', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const result = await runShiftClickMultiSelectFlow(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runAddAndPersistSignatureTextFields(surface); @@ -867,6 +986,21 @@ test.describe('template editor', () => { }); test.describe('embedded create', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-embed-shift-click', + }); + const result = await runShiftClickMultiSelectFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', @@ -944,6 +1078,22 @@ test.describe('embedded create', () => { }); test.describe('embedded edit', () => { + test('shift+click adds and removes fields from the selection', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'TEMPLATE', + mode: 'edit', + tokenNamePrefix: 'e2e-embed-shift-click', + }); + const result = await runShiftClickMultiSelectFlow(surface); + + await persistEmbeddedEnvelope(surface); + + await assertShiftClickMultiSelectPersistedInDatabase({ + surface, + ...result, + }); + }); + test('add and persist signature/text fields', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', diff --git a/packages/app-tests/e2e/fixtures/konva.ts b/packages/app-tests/e2e/fixtures/konva.ts index 316eb46f4..87973ab97 100644 --- a/packages/app-tests/e2e/fixtures/konva.ts +++ b/packages/app-tests/e2e/fixtures/konva.ts @@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number { pageNumber, elementSelector }, ); }; + +/** + * Returns how many field groups are currently attached to the page's Konva + * transformer, i.e. the size of the active canvas selection. Used to assert + * multi-select behaviour (marquee drag and Shift+click). + */ +export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => { + await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' }); + + return await page.evaluate( + ({ pageNumber }) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva; + + const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`); + + if (!stage) { + return 0; + } + + const transformer = stage.find('Transformer')[0]; + + if (!transformer) { + return 0; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return (transformer as Konva.Transformer).nodes().length; + }, + { pageNumber }, + ); +}; diff --git a/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts index b688c7cd9..23483a48e 100644 --- a/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts +++ b/packages/app-tests/e2e/organisations/organisation-permission-hierarchy.spec.ts @@ -340,3 +340,67 @@ test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () expect(deleted).toBeNull(); }); }); + +test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => { + test('cannot add a member from another organisation to a group', async ({ page }) => { + // Organisation A, where the actor is the owner/admin. + const { user: actor, organisation: organisationA } = await seedUser({ + isPersonalOrganisation: false, + }); + + // A separate organisation B with a member the actor has no authority over. + const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false }); + const [foreignUser] = await seedOrganisationMembers({ + members: [{ name: 'Foreign', organisationRole: 'MEMBER' }], + organisationId: organisationB.id, + }); + + const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id); + + // A custom group the actor legitimately controls in organisation A. + const groupA = await createCustomGroup(organisationA.id, 'MEMBER'); + + await apiSignin({ page, email: actor.email }); + + const res = await trpcMutation(page, 'organisation.group.update', { + id: groupA.id, + memberIds: [foreignMember.id], + }); + + expect(res.ok()).toBeFalsy(); + + const injectedMembership = await prisma.organisationGroupMember.findFirst({ + where: { groupId: groupA.id, organisationMemberId: foreignMember.id }, + }); + + expect(injectedMembership).toBeNull(); + }); + + test('can add a member from the same organisation to a group (positive control)', async ({ page }) => { + const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false }); + + const [memberUser] = await seedOrganisationMembers({ + members: [{ name: 'Member', organisationRole: 'MEMBER' }], + organisationId: organisation.id, + }); + + const member = await getOrganisationMember(memberUser.id, organisation.id); + + const group = await createCustomGroup(organisation.id, 'MEMBER'); + + await apiSignin({ page, email: actor.email }); + + const res = await trpcMutation(page, 'organisation.group.update', { + id: group.id, + memberIds: [member.id], + }); + + expect(res.ok()).toBeTruthy(); + + const membership = await prisma.organisationGroupMember.findFirst({ + where: { groupId: group.id, organisationMemberId: member.id }, + }); + + expect(membership).not.toBeNull(); + }); +}); diff --git a/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts b/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts new file mode 100644 index 000000000..50de44307 --- /dev/null +++ b/packages/app-tests/e2e/recipient/recipient-visibility-access.spec.ts @@ -0,0 +1,72 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { hashString } from '@documenso/lib/server-only/auth/hash'; +import { alphaid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { seedCompletedDocument } from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ mode: 'parallel' }); + +const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => { + const token = `api_${alphaid(16)}`; + + await prisma.apiToken.create({ + data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId }, + }); + + return { token }; +}; + +/** + * Reading a recipient exposes its signing token (a bearer credential), so the + * recipient read must enforce document visibility — a member who cannot read a + * restricted document must not be able to read its recipients either. This + * mirrors the field read, which is asserted as a control below. + */ +test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } }); + + const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(res.status()).toBe(404); + + const body = res.ok() ? await res.json() : null; + expect(body?.token).toBeUndefined(); +}); + +test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id }); + + const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], { + createDocumentOptions: { visibility: DocumentVisibility.ADMIN }, + }); + + const field = await prisma.field.findFirst({ where: { envelopeId: document.id } }); + + test.skip(!field, 'No field seeded on completed document'); + + const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, { + headers: { Authorization: `Bearer ${memberToken}` }, + }); + + expect(res.status()).toBe(404); +}); diff --git a/packages/app-tests/e2e/teams/team-profile-access.spec.ts b/packages/app-tests/e2e/teams/team-profile-access.spec.ts new file mode 100644 index 000000000..ecff18fed --- /dev/null +++ b/packages/app-tests/e2e/teams/team-profile-access.spec.ts @@ -0,0 +1,53 @@ +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { prisma } from '@documenso/prisma'; +import { TeamMemberRole } from '@documenso/prisma/client'; +import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { expect, test } from '@playwright/test'; + +import { apiSignin } from '../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); + +test.describe.configure({ mode: 'parallel' }); + +/** + * Editing the team public profile is a team-management action and must require + * MANAGE_TEAM, consistent with renaming the team or changing its URL. + */ +test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await apiSignin({ page, email: member.email }); + + const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, { + headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() }, + data: JSON.stringify({ + json: { + teamId: team.id, + data: { profileEnabled: true, profileBio: 'edited-by-member' }, + }, + }), + }); + + expect(profileRes.status()).not.toBe(200); + + const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } }); + expect(profile?.enabled ?? false).toBe(false); + expect(profile?.bio ?? '').not.toBe('edited-by-member'); + + // The name/url path of the same route is also management-gated. + const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, { + headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() }, + data: JSON.stringify({ + json: { teamId: team.id, data: { name: 'renamed-by-member' } }, + }), + }); + + expect(nameRes.status()).not.toBe(200); + + const reloaded = await prisma.team.findUnique({ where: { id: team.id } }); + expect(reloaded?.name).not.toBe('renamed-by-member'); + + expect(owner.id).toBeTruthy(); +}); diff --git a/packages/email/template-components/template-document-completed.tsx b/packages/email/template-components/template-document-completed.tsx index 7742c90fa..ec453f315 100644 --- a/packages/email/template-components/template-document-completed.tsx +++ b/packages/email/template-components/template-document-completed.tsx @@ -28,7 +28,11 @@ export const TemplateDocumentCompleted = ({
- + Completed @@ -47,7 +51,7 @@ export const TemplateDocumentCompleted = ({ className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline" href={downloadLink} > - + Download
diff --git a/packages/email/template-components/template-document-pending.tsx b/packages/email/template-components/template-document-pending.tsx index 5022b8b87..c44ae5c81 100644 --- a/packages/email/template-components/template-document-pending.tsx +++ b/packages/email/template-components/template-document-pending.tsx @@ -21,7 +21,7 @@ export const TemplateDocumentPending = ({ documentName, assetBaseUrl }: Template
- + Waiting for others diff --git a/packages/email/template-components/template-document-recipient-signed.tsx b/packages/email/template-components/template-document-recipient-signed.tsx index c35a0f316..42ebe0e10 100644 --- a/packages/email/template-components/template-document-recipient-signed.tsx +++ b/packages/email/template-components/template-document-recipient-signed.tsx @@ -30,7 +30,11 @@ export const TemplateDocumentRecipientSigned = ({
- + Completed diff --git a/packages/email/template-components/template-document-self-signed.tsx b/packages/email/template-components/template-document-self-signed.tsx index 4759ea06f..852843577 100644 --- a/packages/email/template-components/template-document-self-signed.tsx +++ b/packages/email/template-components/template-document-self-signed.tsx @@ -26,7 +26,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ
- + Completed @@ -51,7 +55,11 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ href={signUpUrl} className="mr-4 rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline" > - + Create account @@ -59,7 +67,7 @@ export const TemplateDocumentSelfSigned = ({ documentName, assetBaseUrl }: Templ className="rounded-lg border border-border border-solid px-4 py-2 text-center font-medium text-foreground text-sm no-underline" href="https://documenso.com/pricing" > - + View plans
diff --git a/packages/email/template-components/template-image.tsx b/packages/email/template-components/template-image.tsx index 8f821c10f..b16bcfe60 100644 --- a/packages/email/template-components/template-image.tsx +++ b/packages/email/template-components/template-image.tsx @@ -11,7 +11,7 @@ export const TemplateImage = ({ assetBaseUrl, className, staticAsset }: Template return new URL(path, assetBaseUrl).toString(); }; - return ; + return ; }; export default TemplateImage; diff --git a/packages/email/templates/access-auth-2fa.tsx b/packages/email/templates/access-auth-2fa.tsx index ecff50224..adc66f65a 100644 --- a/packages/email/templates/access-auth-2fa.tsx +++ b/packages/email/templates/access-auth-2fa.tsx @@ -30,9 +30,10 @@ export const AccessAuth2FAEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/admin-user-created.tsx b/packages/email/templates/admin-user-created.tsx index f82ce4605..ccc92020b 100644 --- a/packages/email/templates/admin-user-created.tsx +++ b/packages/email/templates/admin-user-created.tsx @@ -21,8 +21,9 @@ export const AdminUserCreatedTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/bulk-send-complete.tsx b/packages/email/templates/bulk-send-complete.tsx index 8b0bf3b45..cc15ad0c3 100644 --- a/packages/email/templates/bulk-send-complete.tsx +++ b/packages/email/templates/bulk-send-complete.tsx @@ -24,11 +24,14 @@ export const BulkSendCompleteEmail = ({ }: BulkSendCompleteEmailProps) => { const { _ } = useLingui(); + const previewText = msg`Bulk send operation complete for template "${templateName}"`; + return ( - {_(msg`Bulk send operation complete for template "${templateName}"`)} + {_(previewText)} +
diff --git a/packages/email/templates/confirm-email.tsx b/packages/email/templates/confirm-email.tsx index 5024b28b0..f57dcdec9 100644 --- a/packages/email/templates/confirm-email.tsx +++ b/packages/email/templates/confirm-email.tsx @@ -18,8 +18,9 @@ export const ConfirmEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/confirm-team-email.tsx b/packages/email/templates/confirm-team-email.tsx index dfb760d35..6deab57de 100644 --- a/packages/email/templates/confirm-team-email.tsx +++ b/packages/email/templates/confirm-team-email.tsx @@ -30,9 +30,9 @@ export const ConfirmTeamEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-cancel.tsx b/packages/email/templates/document-cancel.tsx index f2eb84c6e..34c077252 100644 --- a/packages/email/templates/document-cancel.tsx +++ b/packages/email/templates/document-cancel.tsx @@ -23,9 +23,10 @@ export const DocumentCancelTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/document-completed.tsx b/packages/email/templates/document-completed.tsx index bc54748aa..b0cff91d3 100644 --- a/packages/email/templates/document-completed.tsx +++ b/packages/email/templates/document-completed.tsx @@ -26,9 +26,9 @@ export const DocumentCompletedEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-created-from-direct-template.tsx b/packages/email/templates/document-created-from-direct-template.tsx index 4ae9aad98..5b8b12abc 100644 --- a/packages/email/templates/document-created-from-direct-template.tsx +++ b/packages/email/templates/document-created-from-direct-template.tsx @@ -33,9 +33,9 @@ export const DocumentCreatedFromDirectTemplateEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-invite.tsx b/packages/email/templates/document-invite.tsx index 5e8d3a6d4..a876680c6 100644 --- a/packages/email/templates/document-invite.tsx +++ b/packages/email/templates/document-invite.tsx @@ -56,9 +56,10 @@ export const DocumentInviteEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
@@ -85,7 +86,7 @@ export const DocumentInviteEmailTemplate = ({ {inviterName}{' '} - + ({inviterEmail}) diff --git a/packages/email/templates/document-pending.tsx b/packages/email/templates/document-pending.tsx index a6f91b60a..f0879b080 100644 --- a/packages/email/templates/document-pending.tsx +++ b/packages/email/templates/document-pending.tsx @@ -20,9 +20,9 @@ export const DocumentPendingEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-recipient-signed.tsx b/packages/email/templates/document-recipient-signed.tsx index df7fed7b8..af125f628 100644 --- a/packages/email/templates/document-recipient-signed.tsx +++ b/packages/email/templates/document-recipient-signed.tsx @@ -28,9 +28,9 @@ export const DocumentRecipientSignedEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-rejected.tsx b/packages/email/templates/document-rejected.tsx index c17348eba..3e343dbb3 100644 --- a/packages/email/templates/document-rejected.tsx +++ b/packages/email/templates/document-rejected.tsx @@ -28,9 +28,10 @@ export function DocumentRejectedEmail({ return ( - {previewText} + {previewText} +
diff --git a/packages/email/templates/document-rejection-confirmed.tsx b/packages/email/templates/document-rejection-confirmed.tsx index 383417e20..e88e31d59 100644 --- a/packages/email/templates/document-rejection-confirmed.tsx +++ b/packages/email/templates/document-rejection-confirmed.tsx @@ -28,9 +28,10 @@ export function DocumentRejectionConfirmedEmail({ return ( - {previewText} + {previewText} +
diff --git a/packages/email/templates/document-reminder.tsx b/packages/email/templates/document-reminder.tsx index 5633b4959..c5759b48d 100644 --- a/packages/email/templates/document-reminder.tsx +++ b/packages/email/templates/document-reminder.tsx @@ -37,9 +37,10 @@ export const DocumentReminderEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/document-self-signed.tsx b/packages/email/templates/document-self-signed.tsx index 3d8657f2d..7c848ff99 100644 --- a/packages/email/templates/document-self-signed.tsx +++ b/packages/email/templates/document-self-signed.tsx @@ -20,9 +20,9 @@ export const DocumentSelfSignedEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/document-super-delete.tsx b/packages/email/templates/document-super-delete.tsx index d0098bf7d..03cb0b0a9 100644 --- a/packages/email/templates/document-super-delete.tsx +++ b/packages/email/templates/document-super-delete.tsx @@ -23,9 +23,10 @@ export const DocumentSuperDeleteEmailTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/forgot-password.tsx b/packages/email/templates/forgot-password.tsx index 16652b99e..93620700a 100644 --- a/packages/email/templates/forgot-password.tsx +++ b/packages/email/templates/forgot-password.tsx @@ -20,9 +20,10 @@ export const ForgotPasswordTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/organisation-account-link-confirmation.tsx b/packages/email/templates/organisation-account-link-confirmation.tsx index 2c66b86fa..aca38b491 100644 --- a/packages/email/templates/organisation-account-link-confirmation.tsx +++ b/packages/email/templates/organisation-account-link-confirmation.tsx @@ -30,8 +30,9 @@ export const OrganisationAccountLinkConfirmationTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/organisation-delete.tsx b/packages/email/templates/organisation-delete.tsx index d78a6a211..81937870c 100644 --- a/packages/email/templates/organisation-delete.tsx +++ b/packages/email/templates/organisation-delete.tsx @@ -34,9 +34,9 @@ export const OrganisationDeleteEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-invite.tsx b/packages/email/templates/organisation-invite.tsx index 14322662b..09961a4d1 100644 --- a/packages/email/templates/organisation-invite.tsx +++ b/packages/email/templates/organisation-invite.tsx @@ -29,9 +29,9 @@ export const OrganisationInviteEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-join.tsx b/packages/email/templates/organisation-join.tsx index 6356aa090..eb676b5d3 100644 --- a/packages/email/templates/organisation-join.tsx +++ b/packages/email/templates/organisation-join.tsx @@ -31,9 +31,9 @@ export const OrganisationJoinEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-leave.tsx b/packages/email/templates/organisation-leave.tsx index f2d1b6313..058b830cb 100644 --- a/packages/email/templates/organisation-leave.tsx +++ b/packages/email/templates/organisation-leave.tsx @@ -31,9 +31,9 @@ export const OrganisationLeaveEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/organisation-limit-alert.tsx b/packages/email/templates/organisation-limit-alert.tsx index 536710d29..5cd04001a 100644 --- a/packages/email/templates/organisation-limit-alert.tsx +++ b/packages/email/templates/organisation-limit-alert.tsx @@ -29,9 +29,9 @@ export const OrganisationLimitAlertEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/recipient-expired.tsx b/packages/email/templates/recipient-expired.tsx index 0592089cc..71a186462 100644 --- a/packages/email/templates/recipient-expired.tsx +++ b/packages/email/templates/recipient-expired.tsx @@ -23,9 +23,10 @@ export const RecipientExpiredTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/recipient-removed-from-document.tsx b/packages/email/templates/recipient-removed-from-document.tsx index a495edbb8..5035c9679 100644 --- a/packages/email/templates/recipient-removed-from-document.tsx +++ b/packages/email/templates/recipient-removed-from-document.tsx @@ -22,9 +22,10 @@ export const RecipientRemovedFromDocumentTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/reset-password.tsx b/packages/email/templates/reset-password.tsx index 3c4a67c8f..19c5979a3 100644 --- a/packages/email/templates/reset-password.tsx +++ b/packages/email/templates/reset-password.tsx @@ -22,9 +22,10 @@ export const ResetPasswordTemplate = ({ return ( - {_(previewText)} + {_(previewText)} +
diff --git a/packages/email/templates/team-delete.tsx b/packages/email/templates/team-delete.tsx index 336b46d62..59ca46243 100644 --- a/packages/email/templates/team-delete.tsx +++ b/packages/email/templates/team-delete.tsx @@ -29,9 +29,9 @@ export const TeamDeleteEmailTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/email/templates/team-email-removed.tsx b/packages/email/templates/team-email-removed.tsx index 0da2b3554..7543c8789 100644 --- a/packages/email/templates/team-email-removed.tsx +++ b/packages/email/templates/team-email-removed.tsx @@ -30,9 +30,9 @@ export const TeamEmailRemovedTemplate = ({ return ( - {_(previewText)} - + {_(previewText)} +
diff --git a/packages/lib/server-only/document/cancel-document.ts b/packages/lib/server-only/document/cancel-document.ts index 3ac41ca6e..6496e0908 100644 --- a/packages/lib/server-only/document/cancel-document.ts +++ b/packages/lib/server-only/document/cancel-document.ts @@ -8,8 +8,9 @@ import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '../ import type { ApiRequestMetadata } from '../../universal/extract-request-metadata'; import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import type { EnvelopeIdOptions } from '../../utils/envelope'; -import { mapSecondaryIdToDocumentId, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; +import { mapSecondaryIdToDocumentId } from '../../utils/envelope'; import { isMemberManagerOrAbove } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { getMemberRoles } from '../team/get-member-roles'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; @@ -22,9 +23,18 @@ export type CancelDocumentOptions = { }; export const cancelDocument = async ({ id, userId, teamId, reason, requestMetadata }: CancelDocumentOptions) => { - // Note: This is an unsafe request, we validate the ownership/permission later in the function. - const envelope = await prisma.envelope.findUnique({ - where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), + // Resolve the envelope through the visibility-aware helper so the caller must + // have read access (ownership OR team membership with sufficient visibility OR + // team-email). This prevents cancelling a document the caller cannot see. + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id, + userId, + teamId, + type: EnvelopeType.DOCUMENT, + }); + + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, include: { recipients: true, documentMeta: true, @@ -49,16 +59,6 @@ export const cancelDocument = async ({ id, userId, teamId, reason, requestMetada .then((roles) => roles.teamRole) .catch(() => null); - const isUserTeamMember = teamRole !== null; - - // Callers with no relationship to the document must not be able to determine - // whether it exists, so respond as if it was not found. - if (!isUserOwner && !isUserTeamMember) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Document not found', - }); - } - const isPrivilegedTeamMember = teamRole && isMemberManagerOrAbove(teamRole); // The document is visible to the caller, but cancelling requires elevated permissions. diff --git a/packages/lib/server-only/document/delete-document.ts b/packages/lib/server-only/document/delete-document.ts index 8eed2702f..0a4456d5c 100644 --- a/packages/lib/server-only/document/delete-document.ts +++ b/packages/lib/server-only/document/delete-document.ts @@ -13,7 +13,7 @@ import { createDocumentAuditLogData } from '../../utils/document-audit-logs'; import { type EnvelopeIdOptions, unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; import { isRecipientEmailValidForSending } from '../../utils/recipients'; import { getEmailContext } from '../email/get-email-context'; -import { getMemberRoles } from '../team/get-member-roles'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; export type DeleteDocumentOptions = { @@ -36,7 +36,9 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De }); } - // Note: This is an unsafe request, we validate the ownership later in the function. + // Note: This is an unsafe request. It is used purely to resolve the recipient + // self-hide path below. The authoritative delete authorization is performed + // via the visibility-aware `getEnvelopeWhereInput` helper. const envelope = await prisma.envelope.findUnique({ where: unsafeBuildEnvelopeIdQuery(id, EnvelopeType.DOCUMENT), include: { @@ -51,27 +53,36 @@ export const deleteDocument = async ({ id, userId, teamId, requestMetadata }: De }); } - const isUserTeamMember = await getMemberRoles({ - teamId: envelope.teamId, - reference: { - type: 'User', - id: userId, - }, + // Determine whether the user has authorized delete access using the + // visibility-aware helper. This enforces ownership OR (team membership AND + // the document's visibility is permitted for the member's role) OR team-email + // access. A bare team member without sufficient visibility will resolve to + // null here and therefore must not be able to delete the document. + const hasDeleteAccess = await getEnvelopeWhereInput({ + id, + userId, + teamId, + type: EnvelopeType.DOCUMENT, }) - .then(() => true) + .then(({ envelopeWhereInput }) => + prisma.envelope.findFirst({ + where: envelopeWhereInput, + select: { id: true }, + }), + ) + .then((result) => Boolean(result)) .catch(() => false); - const isUserOwner = envelope.userId === userId; const userRecipient = envelope.recipients.find((recipient) => recipient.email === user.email); - if (!isUserOwner && !isUserTeamMember && !userRecipient) { + if (!hasDeleteAccess && !userRecipient) { throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Not allowed', }); } // Handle hard or soft deleting the actual document if user has permission. - if (isUserOwner || isUserTeamMember) { + if (hasDeleteAccess) { await handleDocumentOwnerDelete({ envelope, user, diff --git a/packages/lib/server-only/envelope-attachment/create-attachment.ts b/packages/lib/server-only/envelope-attachment/create-attachment.ts index b753378cf..0e16639f0 100644 --- a/packages/lib/server-only/envelope-attachment/create-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/create-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type CreateAttachmentOptions = { envelopeId: string; @@ -15,11 +15,15 @@ export type CreateAttachmentOptions = { }; export const createAttachment = async ({ envelopeId, teamId, userId, data }: CreateAttachmentOptions) => { + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }); + const envelope = await prisma.envelope.findFirst({ - where: { - id: envelopeId, - team: buildTeamWhereQuery({ teamId, userId }), - }, + where: envelopeWhereInput, }); if (!envelope) { diff --git a/packages/lib/server-only/envelope-attachment/delete-attachment.ts b/packages/lib/server-only/envelope-attachment/delete-attachment.ts index 67f4e7974..f04e6cbb9 100644 --- a/packages/lib/server-only/envelope-attachment/delete-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/delete-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type DeleteAttachmentOptions = { id: string; @@ -14,9 +14,6 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO const attachment = await prisma.envelopeAttachment.findFirst({ where: { id, - envelope: { - team: buildTeamWhereQuery({ teamId, userId }), - }, }, include: { envelope: true, @@ -29,6 +26,24 @@ export const deleteAttachment = async ({ id, userId, teamId }: DeleteAttachmentO }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: attachment.envelopeId }, + userId, + teamId, + type: null, + }); + + // Additional validation to check the user has visibility-aware access to the envelope. + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Attachment not found', + }); + } + if ( attachment.envelope.status === DocumentStatus.COMPLETED || attachment.envelope.status === DocumentStatus.REJECTED diff --git a/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts b/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts index ff6402fea..1e8b1d25e 100644 --- a/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts +++ b/packages/lib/server-only/envelope-attachment/find-attachments-by-envelope-id.ts @@ -1,7 +1,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type FindAttachmentsByEnvelopeIdOptions = { envelopeId: string; @@ -14,11 +14,15 @@ export const findAttachmentsByEnvelopeId = async ({ userId, teamId, }: FindAttachmentsByEnvelopeIdOptions) => { + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + userId, + teamId, + type: null, + }); + const envelope = await prisma.envelope.findFirst({ - where: { - id: envelopeId, - team: buildTeamWhereQuery({ teamId, userId }), - }, + where: envelopeWhereInput, }); if (!envelope) { diff --git a/packages/lib/server-only/envelope-attachment/update-attachment.ts b/packages/lib/server-only/envelope-attachment/update-attachment.ts index c07e902a9..1758c2b60 100644 --- a/packages/lib/server-only/envelope-attachment/update-attachment.ts +++ b/packages/lib/server-only/envelope-attachment/update-attachment.ts @@ -2,7 +2,7 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { prisma } from '@documenso/prisma'; import { DocumentStatus } from '@prisma/client'; -import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type UpdateAttachmentOptions = { id: string; @@ -15,9 +15,6 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac const attachment = await prisma.envelopeAttachment.findFirst({ where: { id, - envelope: { - team: buildTeamWhereQuery({ teamId, userId }), - }, }, include: { envelope: true, @@ -30,6 +27,24 @@ export const updateAttachment = async ({ id, teamId, userId, data }: UpdateAttac }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { type: 'envelopeId', id: attachment.envelopeId }, + userId, + teamId, + type: null, + }); + + // Additional validation to check the user has visibility-aware access to the envelope. + const envelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Attachment not found', + }); + } + if ( attachment.envelope.status === DocumentStatus.COMPLETED || attachment.envelope.status === DocumentStatus.REJECTED diff --git a/packages/lib/server-only/public-api/get-user-by-token.ts b/packages/lib/server-only/public-api/get-user-by-token.ts deleted file mode 100644 index 887730b5c..000000000 --- a/packages/lib/server-only/public-api/get-user-by-token.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { prisma } from '@documenso/prisma'; - -import { AppError, AppErrorCode } from '../../errors/app-error'; -import { hashString } from '../auth/hash'; - -export const getUserByApiToken = async ({ token }: { token: string }) => { - const hashedToken = hashString(token); - - const user = await prisma.user.findFirst({ - where: { - apiTokens: { - some: { - token: hashedToken, - }, - }, - }, - include: { - apiTokens: true, - }, - }); - - if (!user) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'Invalid token', - statusCode: 401, - }); - } - - const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken); - - // This should be impossible but we need to satisfy TypeScript - if (!retrievedToken) { - throw new AppError(AppErrorCode.UNAUTHORIZED, { - message: 'Invalid token', - statusCode: 401, - }); - } - - if (retrievedToken.expires && retrievedToken.expires < new Date()) { - throw new Error('Expired token'); - } - - return user; -}; diff --git a/packages/lib/server-only/recipient/get-recipient-by-id.ts b/packages/lib/server-only/recipient/get-recipient-by-id.ts index 1f0a48855..0a8df960b 100644 --- a/packages/lib/server-only/recipient/get-recipient-by-id.ts +++ b/packages/lib/server-only/recipient/get-recipient-by-id.ts @@ -4,6 +4,7 @@ import { EnvelopeType } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { mapSecondaryIdToDocumentId, mapSecondaryIdToTemplateId } from '../../utils/envelope'; import { buildTeamWhereQuery } from '../../utils/teams'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type GetRecipientByIdOptions = { recipientId: number; @@ -41,6 +42,27 @@ export const getRecipientById = async ({ recipientId, userId, teamId, type }: Ge }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: recipient.envelopeId, + }, + type, + userId, + teamId, + }); + + // Additional validation to check visibility. + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + const legacyId = { documentId: type === EnvelopeType.DOCUMENT ? mapSecondaryIdToDocumentId(recipient.envelope.secondaryId) : null, templateId: type === EnvelopeType.TEMPLATE ? mapSecondaryIdToTemplateId(recipient.envelope.secondaryId) : null, diff --git a/packages/lib/server-only/share/create-or-get-share-link.ts b/packages/lib/server-only/share/create-or-get-share-link.ts index 64842edcb..84917d74b 100644 --- a/packages/lib/server-only/share/create-or-get-share-link.ts +++ b/packages/lib/server-only/share/create-or-get-share-link.ts @@ -5,6 +5,7 @@ import { match, P } from 'ts-pattern'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { alphaid } from '../../universal/id'; import { unsafeBuildEnvelopeIdQuery } from '../../utils/envelope'; +import { getEnvelopeWhereInput } from '../envelope/get-envelope-by-id'; export type CreateSharingIdOptions = | { @@ -27,6 +28,7 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha ), select: { id: true, + teamId: true, }, }); @@ -46,6 +48,31 @@ export const createOrGetShareLink = async ({ documentId, ...options }: CreateSha .then((recipient) => recipient?.email); }) .with({ userId: P.number }, async ({ userId }) => { + // Ensure the authenticated user actually has visibility-aware access to the + // envelope before allowing them to create a share link. The share route does + // not carry a teamId, so we derive it from the envelope and reuse the canonical + // visibility check (owner OR team member with sufficient visibility). + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'documentId', + id: documentId, + }, + userId, + teamId: envelope.teamId, + type: EnvelopeType.DOCUMENT, + }); + + const accessibleEnvelope = await prisma.envelope.findFirst({ + where: envelopeWhereInput, + select: { + id: true, + }, + }); + + if (!accessibleEnvelope) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + return await prisma.user .findFirst({ where: { diff --git a/packages/lib/server-only/team/delete-team.ts b/packages/lib/server-only/team/delete-team.ts index 182a004a0..b9393db84 100644 --- a/packages/lib/server-only/team/delete-team.ts +++ b/packages/lib/server-only/team/delete-team.ts @@ -85,6 +85,7 @@ export const deleteTeam = async ({ userId, teamId }: DeleteTeamOptions) => { // Purge all internal organisation groups that have no teams. await tx.organisationGroup.deleteMany({ where: { + organisationId: team.organisationId, type: OrganisationGroupType.INTERNAL_TEAM, teamGroups: { none: {}, diff --git a/packages/lib/server-only/team/get-team-public-profile.ts b/packages/lib/server-only/team/get-team-public-profile.ts index 1f0b289d0..99e46ced2 100644 --- a/packages/lib/server-only/team/get-team-public-profile.ts +++ b/packages/lib/server-only/team/get-team-public-profile.ts @@ -3,7 +3,6 @@ import type { TeamProfile } from '@prisma/client'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { buildTeamWhereQuery } from '../../utils/teams'; -import { updateTeamPublicProfile } from './update-team-public-profile'; export type GetTeamPublicProfileOptions = { userId: number; @@ -32,25 +31,24 @@ export const getTeamPublicProfile = async ({ }); } - // Create and return the public profile. + // Lazily initialize a disabled public profile on first access. Membership is + // already verified by the query above, so this system initialization does not + // impose the MANAGE_TEAM gate that updateTeamPublicProfile enforces for writes. if (!team.profile) { - const { url, profile } = await updateTeamPublicProfile({ - userId: userId, - teamId, - data: { + const profile = await prisma.teamProfile.upsert({ + where: { + teamId, + }, + create: { + teamId, enabled: false, }, + update: {}, }); - if (!profile) { - throw new AppError(AppErrorCode.NOT_FOUND, { - message: 'Failed to create public profile', - }); - } - return { profile, - url, + url: team.url, }; } diff --git a/packages/lib/server-only/team/update-team-public-profile.ts b/packages/lib/server-only/team/update-team-public-profile.ts index ca78bd008..c39e31dbc 100644 --- a/packages/lib/server-only/team/update-team-public-profile.ts +++ b/packages/lib/server-only/team/update-team-public-profile.ts @@ -1,3 +1,4 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; import { prisma } from '@documenso/prisma'; import { buildTeamWhereQuery } from '../../utils/teams'; @@ -13,7 +14,11 @@ export type UpdatePublicProfileOptions = { export const updateTeamPublicProfile = async ({ userId, teamId, data }: UpdatePublicProfileOptions) => { return await prisma.team.update({ - where: buildTeamWhereQuery({ teamId, userId }), + where: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), data: { profile: { upsert: { diff --git a/packages/lib/server-only/user/forgot-password.ts b/packages/lib/server-only/user/forgot-password.ts index 7197f50c7..86d1c1aaa 100644 --- a/packages/lib/server-only/user/forgot-password.ts +++ b/packages/lib/server-only/user/forgot-password.ts @@ -18,31 +18,26 @@ export const forgotPassword = async ({ email }: { email: string }) => { return; } - // Find a token that was created in the last hour and hasn't expired - // const existingToken = await prisma.passwordResetToken.findFirst({ - // where: { - // userId: user.id, - // expiry: { - // gt: new Date(), - // }, - // createdAt: { - // gt: new Date(Date.now() - ONE_HOUR), - // }, - // }, - // }); - - // if (existingToken) { - // return; - // } - const token = crypto.randomBytes(18).toString('hex'); - await prisma.passwordResetToken.create({ - data: { - token, - expiry: new Date(Date.now() + ONE_HOUR), - userId: user.id, - }, + // Invalidate any prior reset tokens for this user before issuing a new one, so + // only a single token is ever live at a time. We still always issue a fresh + // token (and email) so the user can request a new link if a prior email never + // arrived, while bounding the number of usable tokens to one. + await prisma.$transaction(async (tx) => { + await tx.passwordResetToken.deleteMany({ + where: { + userId: user.id, + }, + }); + + await tx.passwordResetToken.create({ + data: { + token, + expiry: new Date(Date.now() + ONE_HOUR), + userId: user.id, + }, + }); }); await sendForgotPassword({ diff --git a/packages/lib/server-only/webhooks/zapier/subscribe.ts b/packages/lib/server-only/webhooks/zapier/subscribe.ts index 370995dd8..c05c99250 100644 --- a/packages/lib/server-only/webhooks/zapier/subscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/subscribe.ts @@ -1,4 +1,6 @@ -import { AppError } from '@documenso/lib/errors/app-error'; +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { assertNotPrivateUrl } from '../assert-webhook-url'; @@ -9,14 +11,36 @@ export const subscribeHandler = async (req: Request) => { const authorization = req.headers.get('authorization'); if (!authorization) { - return new Response('Unauthorized', { status: 401 }); + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); } const { webhookUrl, eventTrigger } = await req.json(); await assertNotPrivateUrl(webhookUrl); - const result = await validateApiToken({ authorization }); + const result = await validateApiToken({ authorization }).catch(() => { + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); + }); + + const userId = result.userId ?? result.user.id; + const teamId = result.teamId ?? undefined; + + // Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the + // tRPC webhook mutations (create-webhook.ts). Guards against stale-privilege + // use of a token minted while the holder was privileged. + const team = await prisma.team.findFirst({ + where: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), + }); + + if (!team) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'You do not have permission to manage webhooks for this team', + }); + } const createdWebhook = await prisma.webhook.create({ data: { @@ -24,15 +48,19 @@ export const subscribeHandler = async (req: Request) => { eventTriggers: [eventTrigger], secret: null, enabled: true, - userId: result.userId ?? result.user.id, - teamId: result.teamId ?? undefined, + userId, + teamId, }, }); return Response.json(createdWebhook); } catch (err) { if (err instanceof AppError) { - return Response.json({ message: err.message }, { status: 400 }); + // Map authorization failures to 401, keep other AppErrors as 400 to + // preserve the existing Zapier contract (e.g. invalid webhook URL). + const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400; + + return Response.json({ message: err.message }, { status }); } console.error(err); diff --git a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts index 4772c164b..df21c0b65 100644 --- a/packages/lib/server-only/webhooks/zapier/unsubscribe.ts +++ b/packages/lib/server-only/webhooks/zapier/unsubscribe.ts @@ -1,3 +1,6 @@ +import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; import { validateApiToken } from './validateApiToken'; @@ -7,23 +10,42 @@ export const unsubscribeHandler = async (req: Request) => { const authorization = req.headers.get('authorization'); if (!authorization) { - return new Response('Unauthorized', { status: 401 }); + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); } const { webhookId } = await req.json(); - const result = await validateApiToken({ authorization }); + const result = await validateApiToken({ authorization }).catch(() => { + throw new AppError(AppErrorCode.UNAUTHORIZED, { message: 'Unauthorized' }); + }); + const userId = result.userId ?? result.user.id; + const teamId = result.teamId ?? undefined; + + // Re-verify the token holder still has MANAGE_TEAM on the team, mirroring the + // tRPC delete-webhook-by-id mutation. Guards against stale-privilege use of a + // token minted while the holder was privileged. const deletedWebhook = await prisma.webhook.delete({ where: { id: webhookId, - userId: result.userId ?? result.user.id, - teamId: result.teamId ?? undefined, + team: buildTeamWhereQuery({ + teamId, + userId, + roles: TEAM_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_TEAM'], + }), }, }); return Response.json(deletedWebhook); } catch (err) { + if (err instanceof AppError) { + // Map authorization failures to 401, keep other AppErrors as 400 to + // preserve the existing Zapier contract. + const status = err.code === AppErrorCode.UNAUTHORIZED ? 401 : 400; + + return Response.json({ message: err.message }, { status }); + } + console.error(err); return Response.json( diff --git a/packages/lib/utils/is-http-url.test.ts b/packages/lib/utils/is-http-url.test.ts new file mode 100644 index 000000000..3454f32eb --- /dev/null +++ b/packages/lib/utils/is-http-url.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { isHttpUrl, toSafeHref } from './is-http-url'; + +describe('isHttpUrl', () => { + it('accepts http and https URLs', () => { + expect(isHttpUrl('http://example.com')).toBe(true); + expect(isHttpUrl('https://example.com/path?q=1#hash')).toBe(true); + expect(isHttpUrl('HTTPS://EXAMPLE.COM')).toBe(true); + }); + + it('rejects non-http(s) schemes', () => { + expect(isHttpUrl('javascript:alert(1)')).toBe(false); + expect(isHttpUrl('JavaScript:alert(1)')).toBe(false); + expect(isHttpUrl('data:text/html,')).toBe(false); + expect(isHttpUrl('vbscript:msgbox(1)')).toBe(false); + expect(isHttpUrl('file:///etc/passwd')).toBe(false); + }); + + it('rejects non-absolute or unparseable values', () => { + expect(isHttpUrl('not a url')).toBe(false); + expect(isHttpUrl('/relative/path')).toBe(false); + expect(isHttpUrl('')).toBe(false); + }); + + it('does not treat leading whitespace tricks as safe', () => { + // `new URL` trims leading control chars; ensure a smuggled scheme is rejected. + expect(isHttpUrl(' javascript:alert(1)')).toBe(false); + expect(isHttpUrl('java\tscript:alert(1)')).toBe(false); + }); +}); + +describe('toSafeHref', () => { + it('returns the URL when it is http(s)', () => { + expect(toSafeHref('https://example.com')).toBe('https://example.com'); + }); + + it('returns undefined for dangerous or empty values', () => { + expect(toSafeHref('javascript:alert(1)')).toBeUndefined(); + expect(toSafeHref('data:text/html,x')).toBeUndefined(); + expect(toSafeHref('')).toBeUndefined(); + expect(toSafeHref(null)).toBeUndefined(); + expect(toSafeHref(undefined)).toBeUndefined(); + }); +}); diff --git a/packages/lib/utils/is-http-url.ts b/packages/lib/utils/is-http-url.ts new file mode 100644 index 000000000..e5727349b --- /dev/null +++ b/packages/lib/utils/is-http-url.ts @@ -0,0 +1,32 @@ +const ALLOWED_PROTOCOLS = ['http', 'https']; + +/** + * Returns true only when `value` parses as an absolute URL using the http or + * https protocol. + * + * Zod's `.url()` accepts any parseable URL, including non-web schemes. Use this + * to restrict user-supplied URLs to http(s) before they are stored or rendered + * as a link. + */ +export const isHttpUrl = (value: string) => { + try { + const url = new URL(value); + + return ALLOWED_PROTOCOLS.includes(url.protocol.slice(0, -1).toLowerCase()); + } catch { + return false; + } +}; + +/** + * Returns the value to use for a link `href` only when it is an http(s) URL, + * otherwise `undefined`. Use this when rendering user-supplied URLs as anchors, + * including for older rows stored before URL validation was in place. + */ +export const toSafeHref = (value: string | null | undefined): string | undefined => { + if (!value || !isHttpUrl(value)) { + return undefined; + } + + return value; +}; diff --git a/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts b/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts index f07be361d..5a8484381 100644 --- a/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts +++ b/packages/trpc/server/envelope-router/attachment/create-attachment.types.ts @@ -1,3 +1,4 @@ +import { isHttpUrl } from '@documenso/lib/utils/is-http-url'; import { z } from 'zod'; import type { TrpcRouteMeta } from '../../trpc'; @@ -16,7 +17,7 @@ export const ZCreateAttachmentRequestSchema = z.object({ envelopeId: z.string(), data: z.object({ label: z.string().min(1, 'Label is required'), - data: z.string().url('Must be a valid URL'), + data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'), }), }); diff --git a/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts b/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts index 2acff8257..2f60e1fc2 100644 --- a/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts +++ b/packages/trpc/server/envelope-router/attachment/update-attachment.types.ts @@ -1,3 +1,4 @@ +import { isHttpUrl } from '@documenso/lib/utils/is-http-url'; import { z } from 'zod'; import { ZSuccessResponseSchema } from '../../schema'; @@ -17,7 +18,7 @@ export const ZUpdateAttachmentRequestSchema = z.object({ id: z.string(), data: z.object({ label: z.string().min(1, 'Label is required'), - data: z.string().url('Must be a valid URL'), + data: z.string().url('Must be a valid URL').refine(isHttpUrl, 'URL must use the http or https protocol'), }), }); diff --git a/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts index 3e43033ac..2c725d7f8 100644 --- a/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts +++ b/packages/trpc/server/envelope-router/download-envelope-audit-log-pdf.types.ts @@ -5,7 +5,7 @@ import type { TrpcRouteMeta } from '../trpc'; export const downloadEnvelopeAuditLogPdfMeta: TrpcRouteMeta = { openapi: { method: 'GET', - path: '/envelope/{envelopeId}/audit-log/pdf', + path: '/envelope/{envelopeId}/audit-log/download', summary: 'Download envelope audit log PDF', description: 'Download the audit log for a document as a PDF.', tags: ['Envelope'], diff --git a/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts index eeab2be69..b35e7aa11 100644 --- a/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts +++ b/packages/trpc/server/envelope-router/download-envelope-certificate-pdf.types.ts @@ -5,7 +5,7 @@ import type { TrpcRouteMeta } from '../trpc'; export const downloadEnvelopeCertificatePdfMeta: TrpcRouteMeta = { openapi: { method: 'GET', - path: '/envelope/{envelopeId}/certificate/pdf', + path: '/envelope/{envelopeId}/certificate/download', summary: 'Download envelope certificate PDF', description: 'Download the signing certificate for a completed document as a PDF.', tags: ['Envelope'], diff --git a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts index 7ef3e8bc6..4372b9291 100644 --- a/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts +++ b/packages/trpc/server/envelope-router/envelope-recipients/get-envelope-recipient.ts @@ -1,4 +1,5 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { buildTeamWhereQuery } from '@documenso/lib/utils/teams'; import { prisma } from '@documenso/prisma'; @@ -41,5 +42,26 @@ export const getEnvelopeRecipientRoute = authenticatedProcedure }); } + const { envelopeWhereInput } = await getEnvelopeWhereInput({ + id: { + type: 'envelopeId', + id: recipient.envelopeId, + }, + type: null, + userId: user.id, + teamId, + }); + + // Additional validation to check visibility. + const envelope = await prisma.envelope.findUnique({ + where: envelopeWhereInput, + }); + + if (!envelope) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + return recipient; }); diff --git a/packages/trpc/server/organisation-router/update-organisation-group.ts b/packages/trpc/server/organisation-router/update-organisation-group.ts index 06d45d763..c5be77bd2 100644 --- a/packages/trpc/server/organisation-router/update-organisation-group.ts +++ b/packages/trpc/server/organisation-router/update-organisation-group.ts @@ -38,6 +38,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure }, include: { organisationGroupMembers: true, + organisation: { + include: { + members: { + select: { + id: true, + }, + }, + }, + }, }, }); @@ -78,6 +87,15 @@ export const updateOrganisationGroupRoute = authenticatedProcedure const groupMemberIds = unique(data.memberIds || []); + // Validate that members belong to the same organisation as the group. + groupMemberIds.forEach((memberId) => { + const member = organisationGroup.organisation.members.find(({ id }) => id === memberId); + + if (!member) { + throw new AppError(AppErrorCode.NOT_FOUND); + } + }); + const membersToDelete = organisationGroup.organisationGroupMembers.filter( (member) => !groupMemberIds.includes(member.organisationMemberId), );