diff --git a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx index beaf80a2d..05b6a08fe 100644 --- a/apps/remix/app/components/tables/templates-table-action-dropdown.tsx +++ b/apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -1,14 +1,10 @@ import { useState } from 'react'; import { Trans } from '@lingui/react/macro'; -import { - DocumentStatus, - EnvelopeType, - type Recipient, - type TemplateDirectLink, -} from '@prisma/client'; +import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client'; import { Copy, + Download, Edit, FolderIcon, MoreHorizontal, @@ -30,6 +26,7 @@ import { } from '@documenso/ui/primitives/dropdown-menu'; import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog'; +import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog'; import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog'; import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog'; import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog'; @@ -77,87 +74,94 @@ export const TemplatesTableActionDropdown = ({ Action - - - - Edit - - - - {canMutate && ( - setRenameDialogOpen(true)}> - - Rename - - )} - - {canMutate && ( - e.preventDefault()}> -
- - Duplicate -
- - } - /> - )} - - {canMutate && ( - - - Direct link + e.preventDefault()}> +
+ + Download
- } - /> - )} - - setMoveToFolderDialogOpen(true)}> - - Move to Folder - + + } + /> {canMutate && ( - - - Bulk Send via CSV - - } - /> - )} + <> + + + + Edit + + - {canMutate && ( - e.preventDefault()}> -
- - Delete + setRenameDialogOpen(true)}> + + Rename + + + e.preventDefault()}> +
+ + Duplicate +
+ + } + /> + + + + Direct link
- - } - /> + } + /> + + setMoveToFolderDialogOpen(true)}> + + Move to Folder + + + + + Bulk Send via CSV + + } + /> + + e.preventDefault()}> +
+ + Delete +
+ + } + /> + )}
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx index e25b93940..d760539d9 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx @@ -248,20 +248,18 @@ export default function TemplatePage({ params }: Route.ComponentProps) { Template - {isOwnTeamTemplate && ( -
- navigate(templateRootPath)} - /> -
- )} +
+ navigate(templateRootPath)} + /> +

diff --git a/packages/app-tests/e2e/templates/organisation-templates.spec.ts b/packages/app-tests/e2e/templates/organisation-templates.spec.ts index e46d1ffdb..7fa82afb7 100644 --- a/packages/app-tests/e2e/templates/organisation-templates.spec.ts +++ b/packages/app-tests/e2e/templates/organisation-templates.spec.ts @@ -551,3 +551,144 @@ test.describe('Organisation Templates - Adversarial', () => { expect(titles).not.toContain(orgTemplate.title); }); }); + +// ─── API: envelope.item.getManyByToken (org template fallback) ─────────────── + +test.describe('Organisation Templates - envelope.item.getManyByToken API', () => { + test('should allow a sibling team member to fetch envelope items for an org template', async ({ + page, + }) => { + const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario(); + + await apiSignin({ page, email: memberB.email }); + + const { res, json } = await trpcQuery( + page, + 'envelope.item.getManyByToken', + { envelopeId: orgTemplate.id, access: { type: 'user' } }, + teamB.id, + ); + + expect(res.ok()).toBeTruthy(); + + const items = json.result.data.json.data; + expect(Array.isArray(items)).toBe(true); + expect(items.length).toBeGreaterThan(0); + expect(items[0].envelopeId).toBe(orgTemplate.id); + }); + + test('should allow the owning team member to fetch envelope items (own-team path)', async ({ + page, + }) => { + const { ownerA, teamA, orgTemplate } = await seedOrgTemplateScenario(); + + await apiSignin({ page, email: ownerA.email }); + + const { res, json } = await trpcQuery( + page, + 'envelope.item.getManyByToken', + { envelopeId: orgTemplate.id, access: { type: 'user' } }, + teamA.id, + ); + + expect(res.ok()).toBeTruthy(); + + const items = json.result.data.json.data; + expect(items.length).toBeGreaterThan(0); + expect(items[0].envelopeId).toBe(orgTemplate.id); + }); + + test('should reject a user outside the organisation', async ({ page }) => { + const { orgTemplate } = await seedOrgTemplateScenario(); + const { user: outsider, team: outsiderTeam } = await seedUser(); + + await apiSignin({ page, email: outsider.email }); + + const { res } = await trpcQuery( + page, + 'envelope.item.getManyByToken', + { envelopeId: orgTemplate.id, access: { type: 'user' } }, + outsiderTeam.id, + ); + + expect(res.ok()).toBeFalsy(); + }); + + test('should reject fetching items for a PRIVATE template from a sibling team', async ({ + page, + }) => { + const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); + + const privateTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Private Items ${nanoid()}`, + templateType: TemplateType.PRIVATE, + }, + }); + + await apiSignin({ page, email: memberB.email }); + + const { res } = await trpcQuery( + page, + 'envelope.item.getManyByToken', + { envelopeId: privateTemplate.id, access: { type: 'user' } }, + teamB.id, + ); + + expect(res.ok()).toBeFalsy(); + }); + + test('should respect document visibility for the viewer team role', async ({ page }) => { + const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario(); + + const adminOnlyTemplate = await seedBlankTemplate(ownerA, teamA.id, { + createTemplateOptions: { + title: `Items Admin Only ${nanoid()}`, + templateType: TemplateType.ORGANISATION, + visibility: 'ADMIN', + }, + }); + + // memberB is a MEMBER on teamB — must not be able to read items for an ADMIN-only template. + await apiSignin({ page, email: memberB.email }); + + const { res: memberRes } = await trpcQuery( + page, + 'envelope.item.getManyByToken', + { envelopeId: adminOnlyTemplate.id, access: { type: 'user' } }, + teamB.id, + ); + + expect(memberRes.ok()).toBeFalsy(); + + await apiSignout({ page }); + + // ownerA is ADMIN on teamA — should succeed via the own-team path. + await apiSignin({ page, email: ownerA.email }); + + const { res: adminRes, json: adminJson } = await trpcQuery( + page, + 'envelope.item.getManyByToken', + { envelopeId: adminOnlyTemplate.id, access: { type: 'user' } }, + teamA.id, + ); + + expect(adminRes.ok()).toBeTruthy(); + expect(adminJson.result.data.json.data.length).toBeGreaterThan(0); + }); + + test('should reject unauthenticated callers using the user access type', async ({ page }) => { + const { orgTemplate, teamB } = await seedOrgTemplateScenario(); + + // No apiSignin — unauthenticated. + + const { res } = await trpcQuery( + page, + 'envelope.item.getManyByToken', + { envelopeId: orgTemplate.id, access: { type: 'user' } }, + teamB.id, + ); + + expect(res.ok()).toBeFalsy(); + }); +}); diff --git a/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts b/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts index 9c61cf4c3..76702b544 100644 --- a/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts +++ b/packages/trpc/server/envelope-router/get-envelope-items-by-token.ts @@ -2,6 +2,7 @@ import { EnvelopeType } from '@prisma/client'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; +import { getOrganisationTemplateWhereInput } from '@documenso/lib/server-only/template/get-organisation-template-by-id'; import { prisma } from '@documenso/prisma'; import { maybeAuthenticatedProcedure } from '../trpc'; @@ -101,7 +102,7 @@ const handleGetEnvelopeItemsByUser = async ({ userId: number; teamId: number; }) => { - const { envelopeWhereInput } = await getEnvelopeWhereInput({ + const { envelopeWhereInput, team: callerTeam } = await getEnvelopeWhereInput({ id: { type: 'envelopeId', id: envelopeId, @@ -111,7 +112,8 @@ const handleGetEnvelopeItemsByUser = async ({ teamId, }); - const envelope = await prisma.envelope.findUnique({ + // Try the standard team-scoped access path first (owner / current team / team email). + let envelope = await prisma.envelope.findUnique({ where: envelopeWhereInput, include: { envelopeItems: { @@ -122,6 +124,28 @@ const handleGetEnvelopeItemsByUser = async ({ }, }); + // Fallback: if the envelope is an ORGANISATION template owned by a sibling team + // in the caller's organisation, allow read access to the items metadata. + // Mirrors the access logic used by `createDocumentFromTemplate` and the + // file-download endpoint's `checkEnvelopeFileAccess` so this route stays in + // sync with where actual file access is granted. + if (!envelope) { + envelope = await prisma.envelope.findFirst({ + where: getOrganisationTemplateWhereInput({ + id: { type: 'envelopeId', id: envelopeId }, + organisationId: callerTeam.organisationId, + teamRole: callerTeam.currentTeamRole, + }), + include: { + envelopeItems: { + include: { + documentData: true, + }, + }, + }, + }); + } + if (!envelope) { throw new AppError(AppErrorCode.NOT_FOUND, { message: 'Envelope could not be found',