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 = ({
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',