- {activeQuery.data && activeQuery.data.count === 0 ? (
+ {activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
diff --git a/apps/remix/app/utils/templates-search-params.ts b/apps/remix/app/utils/templates-search-params.ts
new file mode 100644
index 000000000..b25ee305e
--- /dev/null
+++ b/apps/remix/app/utils/templates-search-params.ts
@@ -0,0 +1,17 @@
+import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
+
+export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const;
+
+/**
+ * Shared nuqs parsers for the templates page URL state.
+ *
+ * Used by the templates page and its filter components so every consumer
+ * parses and serialises the params identically.
+ */
+export const templatesSearchParams = {
+ view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES),
+ ownerIds: parseAsArrayOf(parseAsInteger),
+ page: parseAsInteger,
+ perPage: parseAsInteger,
+ query: parseAsString,
+};
diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts
index 143c6225b..c768a23a5 100644
--- a/packages/app-tests/e2e/documents/find-documents.spec.ts
+++ b/packages/app-tests/e2e/documents/find-documents.spec.ts
@@ -167,6 +167,29 @@ test.describe('Find Documents UI - Personal Context', () => {
await expect(page.getByRole('link', { name: 'Annual Budget Plan', exact: true })).not.toBeVisible();
});
+ test('should reset pagination when the search query changes', async ({ page }) => {
+ const { user: owner, team } = await seedUser();
+
+ await seedDraftDocument(owner, team.id, [], {
+ createDocumentOptions: { title: 'Quarterly Report 2024' },
+ });
+ await seedDraftDocument(owner, team.id, [], {
+ createDocumentOptions: { title: 'Annual Budget Plan' },
+ });
+
+ // Start on a page that would be empty once the search narrows the results.
+ await apiSignin({
+ page,
+ email: owner.email,
+ redirectPath: `/t/${team.url}/documents?page=2&perPage=1`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Quarterly');
+ await page.waitForURL((url) => url.searchParams.get('query') === 'Quarterly' && !url.searchParams.has('page'));
+
+ await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible();
+ });
+
test('should not show deleted documents', async ({ page }) => {
const { user: owner, team } = await seedUser();
diff --git a/packages/app-tests/e2e/templates/manage-templates.spec.ts b/packages/app-tests/e2e/templates/manage-templates.spec.ts
index abe766eff..76d7eab65 100644
--- a/packages/app-tests/e2e/templates/manage-templates.spec.ts
+++ b/packages/app-tests/e2e/templates/manage-templates.spec.ts
@@ -40,6 +40,88 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
});
+test('[TEMPLATES]: search templates by title', async ({ page }) => {
+ const { team, owner } = await seedTeam();
+
+ await seedTemplate({
+ title: 'Quarterly Report Template',
+ userId: owner.id,
+ teamId: team.id,
+ });
+
+ await seedTemplate({
+ title: 'Annual Budget Template',
+ userId: owner.id,
+ teamId: team.id,
+ });
+
+ await apiSignin({
+ page,
+ email: owner.email,
+ redirectPath: `/t/${team.url}/templates`,
+ });
+
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
+
+ await page.getByPlaceholder('Search templates...').fill('Quarterly');
+ await page.waitForURL(/query=Quarterly/);
+
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
+ await expect(page.getByRole('link', { name: 'Quarterly Report Template' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Annual Budget Template' })).not.toBeVisible();
+
+ // Clearing the search should restore the full list and drop the URL param.
+ await page.getByPlaceholder('Search templates...').fill('');
+ await page.waitForURL((url) => !url.searchParams.has('query'));
+
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
+});
+
+test('[TEMPLATES]: filter templates by owner', async ({ page }) => {
+ const { team, owner } = await seedTeam();
+
+ const teamMemberUser = await seedTeamMember({
+ teamId: team.id,
+ name: 'Filter Member',
+ role: TeamMemberRole.MEMBER,
+ });
+
+ await seedTemplate({
+ title: 'Owner Template',
+ userId: owner.id,
+ teamId: team.id,
+ });
+
+ await seedTemplate({
+ title: 'Member Template',
+ userId: teamMemberUser.id,
+ teamId: team.id,
+ });
+
+ await apiSignin({
+ page,
+ email: owner.email,
+ redirectPath: `/t/${team.url}/templates`,
+ });
+
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
+
+ await page.getByTestId('templates-table-owner-filter').click();
+ await page.getByRole('option', { name: 'Filter Member' }).click();
+ await page.waitForURL(/ownerIds=/);
+ await page.keyboard.press('Escape');
+
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
+ await expect(page.getByRole('link', { name: 'Member Template' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Owner Template' })).not.toBeVisible();
+
+ // Reset should clear the owner filter.
+ await page.getByRole('button', { name: 'Reset' }).click();
+ await page.waitForURL((url) => !url.searchParams.has('ownerIds'));
+
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
+});
+
test('[TEMPLATES]: delete template', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({
createTeamMembers: 1,
diff --git a/packages/app-tests/e2e/templates/organisation-templates.spec.ts b/packages/app-tests/e2e/templates/organisation-templates.spec.ts
index 2322cf04d..1e51ca714 100644
--- a/packages/app-tests/e2e/templates/organisation-templates.spec.ts
+++ b/packages/app-tests/e2e/templates/organisation-templates.spec.ts
@@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record {
- test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
+test.describe('Organisation Templates - UI View Filter', () => {
+ test('should show the view filter for non-personal orgs', async ({ page }) => {
const { ownerA, teamA } = await seedOrgTemplateScenario();
await apiSignin({
@@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => {
redirectPath: `/t/${teamA.url}/templates`,
});
- await expect(page.getByTestId('template-tab-team')).toBeVisible();
- await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
+ await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
});
- test('should not show tabs for personal organisations', async ({ page }) => {
+ test('should not show the view filter for personal organisations', async ({ page }) => {
const { user, team } = await seedUser({ isPersonalOrganisation: true });
await apiSignin({
@@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => {
redirectPath: `/t/${team.url}/templates`,
});
- await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
- await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
+ await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
});
});
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
test.describe('Organisation Templates - Listing', () => {
- test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
+ test('should list org templates from other teams under the organisation view', async ({ page }) => {
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
await apiSignin({
@@ -140,17 +138,30 @@ test.describe('Organisation Templates - Listing', () => {
redirectPath: `/t/${teamB.url}/templates`,
});
- // Team tab should show 0 (memberB has no templates on teamB).
- await expect(page.getByTestId('template-tab-team')).toBeVisible();
+ // Team view is active by default (memberB has no templates on teamB).
+ await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
- // Switch to Organisation tab.
- await page.getByTestId('template-tab-organisation').click();
+ // Switch to the organisation view.
+ await page.getByTestId('templates-table-view-filter').click();
+ await page.getByRole('option', { name: 'Organization' }).click();
// Should see the org template from teamA.
await expect(page.getByText(orgTemplate.title)).toBeVisible();
});
- test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
+ test('should use default pagination when URL values are zero', async ({ page }) => {
+ const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
+
+ await apiSignin({
+ page,
+ email: memberB.email,
+ redirectPath: `/t/${teamB.url}/templates?view=organisation&page=0&perPage=0`,
+ });
+
+ await expect(page.getByText(orgTemplate.title)).toBeVisible();
+ });
+
+ test('should not show private templates from other teams under the organisation view', async ({ page }) => {
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
// Create a private template on teamA — should NOT appear in org tab.
diff --git a/packages/app-tests/e2e/templates/test-unauthorized-find-templates-access.spec.ts b/packages/app-tests/e2e/templates/test-unauthorized-find-templates-access.spec.ts
new file mode 100644
index 000000000..659fdfccc
--- /dev/null
+++ b/packages/app-tests/e2e/templates/test-unauthorized-find-templates-access.spec.ts
@@ -0,0 +1,514 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { createTeam } from '@documenso/lib/server-only/team/create-team';
+import { prisma } from '@documenso/prisma';
+import { seedBlankFolder } from '@documenso/prisma/seed/folders';
+import { seedTeamMember } from '@documenso/prisma/seed/teams';
+import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
+import { seedUser } from '@documenso/prisma/seed/users';
+import type { APIResponse, Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+import { DocumentVisibility, FolderType, TeamMemberRole, TemplateType } from '@prisma/client';
+import { customAlphabet } from 'nanoid';
+
+import { apiSignin, apiSignout } from '../fixtures/authentication';
+
+const nanoid = customAlphabet('1234567890abcdef', 10);
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+test.describe.configure({
+ mode: 'parallel',
+});
+
+type FindTemplatesResult = {
+ data: Array<{ title: string; userId: number; teamId: number }>;
+ count: number;
+};
+
+type TrpcResponse = {
+ response: APIResponse;
+ result: FindTemplatesResult | null;
+};
+
+const trpcQuery = async (
+ page: Page,
+ procedure: string,
+ input: Record,
+ teamId?: number,
+): Promise => {
+ const inputParam = encodeURIComponent(JSON.stringify({ json: { page: 1, perPage: 50, ...input } }));
+ const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
+
+ const headers: Record = teamId ? { 'x-team-id': teamId.toString() } : {};
+
+ const response = await page.context().request.get(url, { headers });
+
+ const json = response.ok() ? await response.json() : null;
+ const result: FindTemplatesResult | null = json ? json.result.data.json : null;
+
+ return { response, result };
+};
+
+const FIND_TEMPLATE_PROCEDURES = ['template.findTemplates', 'template.findTemplatesInternal'] as const;
+
+const titlesOf = (res: TrpcResponse) => (res.result?.data ?? []).map((row) => row.title);
+
+const expectRejected = (res: TrpcResponse, status: number) => {
+ expect(res.response.status()).toBe(status);
+ expect(res.result).toBeNull();
+};
+
+// Check both count and data so one cannot be wrong while the other looks fine.
+const expectNoResults = (res: TrpcResponse) => {
+ expect(res.response.ok()).toBeTruthy();
+ expect(res.result?.count).toBe(0);
+ expect(res.result?.data).toEqual([]);
+};
+
+const expectExactTitles = (res: TrpcResponse, titles: string[], teamId: number) => {
+ expect(res.response.ok()).toBeTruthy();
+ expect(res.result?.count).toBe(titles.length);
+ expect(titlesOf(res).sort()).toEqual([...titles].sort());
+ expect(res.result?.data.every((row) => row.teamId === teamId)).toBe(true);
+};
+
+/**
+ * Org A has two teams. teamA is the default team, so every org member is in it.
+ * teamB was created with inheritMembers: false, so only people added directly are in it.
+ * - ownerA: org owner and teamA admin. Owns all the templates below.
+ * - memberA, managerA: member and manager of teamA. Not in teamB.
+ * - memberB: member of teamB, and also a member of teamA because teamA inherits.
+ * - teamA has four templates with the same suffix in the title, one per visibility:
+ * everyone, manager, admin (with a unique externalId and recipient email), and org.
+ *
+ * Org B is a separate org owned by "outsider". No overlap with Org A.
+ *
+ * So: memberB with a teamB header checks that teamA rows never come back.
+ * memberA with a teamB header checks that non-members are rejected.
+ */
+const seedScenario = async () => {
+ const { user: ownerA, organisation, team: teamA } = await seedUser();
+
+ const teamBUrl = `team-b-${nanoid()}`;
+
+ await createTeam({
+ userId: ownerA.id,
+ teamName: `Team B ${teamBUrl}`,
+ teamUrl: teamBUrl,
+ organisationId: organisation.id,
+ inheritMembers: false,
+ });
+
+ const teamB = await prisma.team.findFirstOrThrow({ where: { url: teamBUrl } });
+
+ const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
+ const managerA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MANAGER });
+ const memberB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MEMBER });
+
+ const { user: outsider, team: outsiderTeam } = await seedUser();
+
+ const suffix = nanoid();
+ const hiddenRecipientEmail = `hidden-recipient-${suffix}@example.com`;
+
+ const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
+ createTemplateOptions: {
+ title: `Everyone Template ${suffix}`,
+ visibility: DocumentVisibility.EVERYONE,
+ },
+ });
+
+ const managerTemplate = await seedBlankTemplate(ownerA, teamA.id, {
+ createTemplateOptions: {
+ title: `Manager Template ${suffix}`,
+ visibility: DocumentVisibility.MANAGER_AND_ABOVE,
+ },
+ });
+
+ const adminTemplate = await seedBlankTemplate(ownerA, teamA.id, {
+ createTemplateOptions: {
+ title: `Admin Only Template ${suffix}`,
+ externalId: `admin-external-${suffix}`,
+ visibility: DocumentVisibility.ADMIN,
+ recipients: {
+ create: {
+ email: hiddenRecipientEmail,
+ name: `Hidden Recipient ${suffix}`,
+ token: nanoid(),
+ },
+ },
+ },
+ });
+
+ const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
+ createTemplateOptions: {
+ title: `Org Template ${suffix}`,
+ templateType: TemplateType.ORGANISATION,
+ visibility: DocumentVisibility.EVERYONE,
+ },
+ });
+
+ const allTitles = [everyoneTemplate.title, orgTemplate.title, managerTemplate.title, adminTemplate.title];
+
+ // Which templates each role on teamA should see.
+ const visibilityMatrix = [
+ { caller: memberA, visible: [everyoneTemplate.title, orgTemplate.title] },
+ { caller: managerA, visible: [everyoneTemplate.title, orgTemplate.title, managerTemplate.title] },
+ { caller: ownerA, visible: allTitles },
+ ];
+
+ return {
+ ownerA,
+ memberA,
+ managerA,
+ memberB,
+ teamA,
+ teamB,
+ outsider,
+ outsiderTeam,
+ suffix,
+ everyoneTemplate,
+ managerTemplate,
+ adminTemplate,
+ orgTemplate,
+ hiddenRecipientEmail,
+ allTitles,
+ visibilityMatrix,
+ };
+};
+
+// ─── Not logged in, or using a team header for a team you are not in ─────────
+
+test.describe('Find Templates API - Adversarial: Auth and Team Header', () => {
+ for (const procedure of FIND_TEMPLATE_PROCEDURES) {
+ test(`${procedure}: should reject unauthenticated requests`, async ({ page }) => {
+ const { teamA } = await seedScenario();
+
+ const res = await trpcQuery(page, procedure, {}, teamA.id);
+
+ expectRejected(res, 401);
+ });
+
+ test(`${procedure}: should reject a team header for a team the user is not in`, async ({ page }) => {
+ const { memberA, teamA, teamB, outsider } = await seedScenario();
+
+ const adminA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
+
+ const cases = [
+ { name: 'org member, not in team', caller: memberA, teamId: teamB.id },
+ { name: 'admin of another team', caller: adminA, teamId: teamB.id },
+ { name: 'other organisation', caller: outsider, teamId: teamA.id },
+ { name: 'no team header', caller: memberA, teamId: undefined },
+ ];
+
+ for (const { caller, teamId } of cases) {
+ await apiSignin({ page, email: caller.email });
+
+ const res = await trpcQuery(page, procedure, {}, teamId);
+
+ expectRejected(res, 404);
+
+ await apiSignout({ page });
+ }
+ });
+ }
+
+ test('findOrganisationTemplates: should reject a team header for a team in another org', async ({ page }) => {
+ const { outsider, teamA } = await seedScenario();
+
+ await apiSignin({ page, email: outsider.email });
+
+ const res = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamA.id);
+
+ expectRejected(res, 404);
+ });
+});
+
+// ─── Search must not find templates you cannot see ──────────────────────────
+
+test.describe('Find Templates API - Adversarial: Search', () => {
+ for (const procedure of FIND_TEMPLATE_PROCEDURES) {
+ test(`${procedure}: search must not find templates hidden by role`, async ({ page }) => {
+ const { memberA, teamA, adminTemplate, hiddenRecipientEmail } = await seedScenario();
+
+ await apiSignin({ page, email: memberA.email });
+
+ for (const query of [adminTemplate.title, adminTemplate.externalId, hiddenRecipientEmail]) {
+ const res = await trpcQuery(page, procedure, { query }, teamA.id);
+
+ expectNoResults(res);
+ }
+ });
+
+ test(`${procedure}: search must not find templates from another team`, async ({ page }) => {
+ const { memberB, teamB, everyoneTemplate } = await seedScenario();
+
+ await apiSignin({ page, email: memberB.email });
+
+ const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, teamB.id);
+
+ expectNoResults(res);
+ });
+
+ test(`${procedure}: search must not find templates from another org`, async ({ page }) => {
+ const { outsider, outsiderTeam, everyoneTemplate } = await seedScenario();
+
+ await apiSignin({ page, email: outsider.email });
+
+ const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, outsiderTeam.id);
+
+ expectNoResults(res);
+ });
+
+ test(`${procedure}: list and search show only what each role is allowed to see`, async ({ page }) => {
+ const { teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
+
+ for (const { caller, visible } of visibilityMatrix) {
+ await apiSignin({ page, email: caller.email });
+
+ const listRes = await trpcQuery(page, procedure, {}, teamA.id);
+
+ expectExactTitles(listRes, visible, teamA.id);
+
+ const searchRes = await trpcQuery(page, procedure, { query: suffix }, teamA.id);
+
+ expectExactTitles(searchRes, visible, teamA.id);
+
+ for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
+ const res = await trpcQuery(page, procedure, { query: hidden }, teamA.id);
+
+ expectNoResults(res);
+ }
+
+ await apiSignout({ page });
+ }
+ });
+
+ test(`${procedure}: wildcard search must not show more than allowed`, async ({ page }) => {
+ const { memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
+
+ await apiSignin({ page, email: memberA.email });
+
+ // % and _ are not escaped, so these match everything you are allowed to see.
+ for (const query of ['%', '_', '%%%']) {
+ const res = await trpcQuery(page, procedure, { query }, teamA.id);
+
+ expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
+ }
+
+ for (const query of ['%Admin Only%', '%Manager%']) {
+ const res = await trpcQuery(page, procedure, { query }, teamA.id);
+
+ expectNoResults(res);
+ }
+ });
+ }
+
+ test('findOrganisationTemplates: search must not find templates from another org', async ({ page }) => {
+ const { outsider, outsiderTeam, orgTemplate } = await seedScenario();
+
+ await apiSignin({ page, email: outsider.email });
+
+ const res = await trpcQuery(
+ page,
+ 'template.findOrganisationTemplates',
+ { query: orgTemplate.title },
+ outsiderTeam.id,
+ );
+
+ expectNoResults(res);
+ });
+
+ test('findOrganisationTemplates: search must not find team templates from another team', async ({ page }) => {
+ const { memberB, teamB, everyoneTemplate, managerTemplate, adminTemplate } = await seedScenario();
+
+ await apiSignin({ page, email: memberB.email });
+
+ for (const { title } of [everyoneTemplate, managerTemplate, adminTemplate]) {
+ const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: title }, teamB.id);
+
+ expectNoResults(res);
+ }
+ });
+
+ test('findOrganisationTemplates: shows only what the user role on the requesting team allows', async ({ page }) => {
+ const { ownerA, teamA, teamB, memberB, orgTemplate } = await seedScenario();
+
+ const managerOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
+ createTemplateOptions: {
+ title: `Manager Org Template ${nanoid()}`,
+ templateType: TemplateType.ORGANISATION,
+ visibility: DocumentVisibility.MANAGER_AND_ABOVE,
+ },
+ });
+
+ const adminOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
+ createTemplateOptions: {
+ title: `Admin Org Template ${nanoid()}`,
+ templateType: TemplateType.ORGANISATION,
+ visibility: DocumentVisibility.ADMIN,
+ },
+ });
+
+ const managerB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MANAGER });
+
+ const allOrgTitles = [orgTemplate.title, managerOrgTemplate.title, adminOrgTemplate.title];
+
+ const matrix = [
+ { caller: memberB, visible: [orgTemplate.title] },
+ { caller: managerB, visible: [orgTemplate.title, managerOrgTemplate.title] },
+ ];
+
+ for (const { caller, visible } of matrix) {
+ await apiSignin({ page, email: caller.email });
+
+ const listRes = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamB.id);
+
+ expectExactTitles(listRes, visible, teamA.id);
+
+ for (const hidden of allOrgTitles.filter((title) => !visible.includes(title))) {
+ const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: hidden }, teamB.id);
+
+ expectNoResults(res);
+ }
+
+ await apiSignout({ page });
+ }
+ });
+});
+
+// ─── Owner filter must not reach other teams or skip visibility checks ───────
+
+test.describe('Find Templates API - Adversarial: Owner Filter', () => {
+ const procedure = 'template.findTemplatesInternal';
+
+ test('owner filter must not find templates from another team', async ({ page }) => {
+ const { ownerA, memberB, teamB } = await seedScenario();
+
+ await apiSignin({ page, email: memberB.email });
+
+ const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, teamB.id);
+
+ expectNoResults(res);
+ });
+
+ test('owner filter must not find templates from another org', async ({ page }) => {
+ const { ownerA, outsider, outsiderTeam } = await seedScenario();
+
+ await apiSignin({ page, email: outsider.email });
+
+ const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, outsiderTeam.id);
+
+ expectNoResults(res);
+ });
+
+ test('owning a template only makes it visible in its own team', async ({ page }) => {
+ const { memberB, teamA, teamB } = await seedScenario();
+
+ // memberB is in both teams and owns an admin-only template in each.
+ // They can only see these because they own them. That must not cross teams.
+ const ownedOnA = await seedBlankTemplate(memberB, teamA.id, {
+ createTemplateOptions: { title: `Owned On A ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
+ });
+
+ const ownedOnB = await seedBlankTemplate(memberB, teamB.id, {
+ createTemplateOptions: { title: `Owned On B ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
+ });
+
+ await apiSignin({ page, email: memberB.email });
+
+ const inputs = [
+ {},
+ { ownerIds: [memberB.id] },
+ { query: 'Owned On' },
+ { ownerIds: [memberB.id], query: 'Owned On' },
+ ];
+
+ for (const input of inputs) {
+ // teamB has no other templates, so this is the only row.
+ const fromB = await trpcQuery(page, procedure, input, teamB.id);
+
+ expectExactTitles(fromB, [ownedOnB.title], teamB.id);
+
+ const fromA = await trpcQuery(page, procedure, input, teamA.id);
+
+ expect(fromA.response.ok()).toBeTruthy();
+ expect(titlesOf(fromA)).toContain(ownedOnA.title);
+ expect(titlesOf(fromA)).not.toContain(ownedOnB.title);
+ }
+ });
+
+ test('owner filter shows only what each role is allowed to see', async ({ page }) => {
+ const { ownerA, teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
+
+ const ownerIds = [ownerA.id];
+
+ for (const { caller, visible } of visibilityMatrix) {
+ await apiSignin({ page, email: caller.email });
+
+ const listRes = await trpcQuery(page, procedure, { ownerIds }, teamA.id);
+
+ expectExactTitles(listRes, visible, teamA.id);
+
+ const searchRes = await trpcQuery(page, procedure, { ownerIds, query: suffix }, teamA.id);
+
+ expectExactTitles(searchRes, visible, teamA.id);
+
+ for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
+ const res = await trpcQuery(page, procedure, { ownerIds, query: hidden }, teamA.id);
+
+ expectNoResults(res);
+ }
+
+ await apiSignout({ page });
+ }
+ });
+
+ test('owner filter with unknown user ids returns nothing', async ({ page }) => {
+ const { ownerA, teamA } = await seedScenario();
+
+ await apiSignin({ page, email: ownerA.email });
+
+ const res = await trpcQuery(page, procedure, { ownerIds: [-1, 999999999] }, teamA.id);
+
+ expectNoResults(res);
+ });
+
+ test('owner filter is ignored by the public findTemplates route', async ({ page }) => {
+ const { ownerA, memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
+
+ await apiSignin({ page, email: memberA.email });
+
+ // The public route does not accept ownerIds. It should be dropped, not error.
+ const res = await trpcQuery(page, 'template.findTemplates', { ownerIds: [ownerA.id] }, teamA.id);
+
+ expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
+ });
+});
+
+// ─── Folder filter must not reach other teams ────────────────────────────────
+
+test.describe('Find Templates API - Adversarial: Folder Filter', () => {
+ for (const procedure of FIND_TEMPLATE_PROCEDURES) {
+ test(`${procedure}: folder filter must not find folders from another team`, async ({ page }) => {
+ const { ownerA, teamA, memberB, teamB } = await seedScenario();
+
+ const folderA = await seedBlankFolder(ownerA, teamA.id, {
+ createFolderOptions: { type: FolderType.TEMPLATE },
+ });
+
+ await seedBlankTemplate(ownerA, teamA.id, {
+ createTemplateOptions: {
+ title: `Foldered Template ${nanoid()}`,
+ visibility: DocumentVisibility.EVERYONE,
+ folderId: folderA.id,
+ },
+ });
+
+ await apiSignin({ page, email: memberB.email });
+
+ const res = await trpcQuery(page, procedure, { folderId: folderA.id }, teamB.id);
+
+ expectNoResults(res);
+ });
+ }
+});
diff --git a/packages/lib/server-only/template/build-template-search-filter.ts b/packages/lib/server-only/template/build-template-search-filter.ts
new file mode 100644
index 000000000..890f9b2ab
--- /dev/null
+++ b/packages/lib/server-only/template/build-template-search-filter.ts
@@ -0,0 +1,34 @@
+import type { Prisma } from '@prisma/client';
+
+/**
+ * Builds the search clause for template listings.
+ *
+ * Matches the same fields as the documents search so both pages behave the
+ * same way: title, external ID, and recipient name or email.
+ *
+ * Returns `null` when the query is empty so callers can skip the clause.
+ */
+export const buildTemplateSearchFilter = (query?: string): Prisma.EnvelopeWhereInput | null => {
+ const searchQuery = query?.trim() ?? '';
+
+ if (searchQuery.length === 0) {
+ return null;
+ }
+
+ return {
+ OR: [
+ { title: { contains: searchQuery, mode: 'insensitive' } },
+ { externalId: { contains: searchQuery, mode: 'insensitive' } },
+ {
+ recipients: {
+ some: {
+ OR: [
+ { name: { contains: searchQuery, mode: 'insensitive' } },
+ { email: { contains: searchQuery, mode: 'insensitive' } },
+ ],
+ },
+ },
+ },
+ ],
+ };
+};
diff --git a/packages/lib/server-only/template/find-organisation-templates.ts b/packages/lib/server-only/template/find-organisation-templates.ts
index 77bdddcb7..0c612417a 100644
--- a/packages/lib/server-only/template/find-organisation-templates.ts
+++ b/packages/lib/server-only/template/find-organisation-templates.ts
@@ -5,12 +5,14 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
import { getTeamById } from '../team/get-team';
+import { buildTemplateSearchFilter } from './build-template-search-filter';
export type FindOrganisationTemplatesOptions = {
userId: number;
teamId: number;
page?: number;
perPage?: number;
+ query?: string;
};
export const findOrganisationTemplates = async ({
@@ -18,6 +20,7 @@ export const findOrganisationTemplates = async ({
teamId,
page = 1,
perPage = 10,
+ query,
}: FindOrganisationTemplatesOptions) => {
const [team, { teamRole }] = await Promise.all([
getTeamById({ teamId, userId }),
@@ -30,6 +33,8 @@ export const findOrganisationTemplates = async ({
}),
]);
+ const searchFilter = buildTemplateSearchFilter(query);
+
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: TemplateType.ORGANISATION,
@@ -39,6 +44,7 @@ export const findOrganisationTemplates = async ({
team: {
organisationId: team.organisationId,
},
+ AND: searchFilter ? [searchFilter] : undefined,
};
const templateInclude = {
diff --git a/packages/lib/server-only/template/find-templates.ts b/packages/lib/server-only/template/find-templates.ts
index 7b838c01c..1f87275b8 100644
--- a/packages/lib/server-only/template/find-templates.ts
+++ b/packages/lib/server-only/template/find-templates.ts
@@ -5,6 +5,7 @@ import { EnvelopeType, type Prisma } from '@prisma/client';
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
import type { FindResultResponse } from '../../types/search-params';
import { getMemberRoles } from '../team/get-member-roles';
+import { buildTemplateSearchFilter } from './build-template-search-filter';
export type FindTemplatesOptions = {
userId: number;
@@ -13,6 +14,8 @@ export type FindTemplatesOptions = {
page?: number;
perPage?: number;
folderId?: string;
+ query?: string;
+ ownerIds?: number[];
};
export const findTemplates = async ({
@@ -22,6 +25,8 @@ export const findTemplates = async ({
page = 1,
perPage = 10,
folderId,
+ query,
+ ownerIds,
}: FindTemplatesOptions) => {
const { teamRole } = await getMemberRoles({
teamId,
@@ -31,23 +36,35 @@ export const findTemplates = async ({
},
});
+ const filters: Prisma.EnvelopeWhereInput[] = [
+ { teamId },
+ {
+ OR: [
+ {
+ visibility: {
+ in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
+ },
+ },
+ { userId, teamId },
+ ],
+ },
+ folderId ? { folderId } : { folderId: null },
+ ];
+
+ if (ownerIds && ownerIds.length > 0) {
+ filters.push({ userId: { in: ownerIds } });
+ }
+
+ const searchFilter = buildTemplateSearchFilter(query);
+
+ if (searchFilter) {
+ filters.push(searchFilter);
+ }
+
const where: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.TEMPLATE,
templateType: type,
- AND: [
- { teamId },
- {
- OR: [
- {
- visibility: {
- in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
- },
- },
- { userId, teamId },
- ],
- },
- folderId ? { folderId } : { folderId: null },
- ],
+ AND: filters,
};
const templateInclude = {
diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts
index 4f4eda1e0..097630ee5 100644
--- a/packages/lib/utils/templates.ts
+++ b/packages/lib/utils/templates.ts
@@ -1,8 +1,10 @@
-import type { Envelope, Recipient } from '@prisma/client';
+import type { DocumentMeta, Envelope, Field, Recipient } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
import type { TTemplateLite } from '../types/template';
import { mapSecondaryIdToTemplateId } from './envelope';
+import { mapFieldToLegacyField } from './fields';
+import { mapRecipientToLegacyRecipient } from './recipients';
export const formatDirectTemplatePath = (token: string) => {
return `${NEXT_PUBLIC_WEBAPP_URL()}/d/${token}`;
@@ -67,3 +69,42 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite =>
templateDocumentDataId: '',
};
};
+
+type EnvelopeWithTemplateManyRelations = Envelope & {
+ team: { id: number; url: string; name: string } | null;
+ fields: Field[];
+ recipients: Recipient[];
+ documentMeta: DocumentMeta | null;
+ directLink: { token: string; enabled: boolean } | null;
+};
+
+/**
+ * Maps an envelope (with the relations loaded by the template find functions)
+ * to the legacy "template many" response shape.
+ */
+export const mapEnvelopeToTemplateMany = (envelope: EnvelopeWithTemplateManyRelations) => {
+ const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
+
+ return {
+ id: legacyTemplateId,
+ envelopeId: envelope.id,
+ type: envelope.templateType,
+ visibility: envelope.visibility,
+ externalId: envelope.externalId,
+ title: envelope.title,
+ userId: envelope.userId,
+ teamId: envelope.teamId,
+ authOptions: envelope.authOptions,
+ createdAt: envelope.createdAt,
+ updatedAt: envelope.updatedAt,
+ publicTitle: envelope.publicTitle,
+ publicDescription: envelope.publicDescription,
+ folderId: envelope.folderId,
+ useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
+ team: envelope.team,
+ fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
+ recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
+ templateMeta: envelope.documentMeta,
+ directLink: envelope.directLink,
+ };
+};
diff --git a/packages/trpc/server/template-router/find-templates-internal.ts b/packages/trpc/server/template-router/find-templates-internal.ts
new file mode 100644
index 000000000..dd3e2d182
--- /dev/null
+++ b/packages/trpc/server/template-router/find-templates-internal.ts
@@ -0,0 +1,39 @@
+import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
+import { mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates';
+
+import { authenticatedProcedure } from '../trpc';
+import {
+ ZFindTemplatesInternalRequestSchema,
+ ZFindTemplatesInternalResponseSchema,
+} from './find-templates-internal.types';
+
+export const findTemplatesInternalRoute = authenticatedProcedure
+ .input(ZFindTemplatesInternalRequestSchema)
+ .output(ZFindTemplatesInternalResponseSchema)
+ .query(async ({ input, ctx }) => {
+ const { user, teamId } = ctx;
+
+ const { query, type, folderId, page, perPage, ownerIds } = input;
+
+ ctx.logger.info({
+ input: {
+ folderId,
+ },
+ });
+
+ const result = await findTemplates({
+ userId: user.id,
+ teamId,
+ query,
+ type,
+ folderId,
+ page,
+ perPage,
+ ownerIds,
+ });
+
+ return {
+ ...result,
+ data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
+ };
+ });
diff --git a/packages/trpc/server/template-router/find-templates-internal.types.ts b/packages/trpc/server/template-router/find-templates-internal.types.ts
new file mode 100644
index 000000000..5d0f94eb4
--- /dev/null
+++ b/packages/trpc/server/template-router/find-templates-internal.types.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+import { ZFindTemplatesRequestSchema, ZFindTemplatesResponseSchema } from './schema';
+
+export const ZFindTemplatesInternalRequestSchema = ZFindTemplatesRequestSchema.extend({
+ ownerIds: z.array(z.number()).optional(),
+});
+
+export const ZFindTemplatesInternalResponseSchema = ZFindTemplatesResponseSchema;
+
+export type TFindTemplatesInternalRequest = z.infer;
+export type TFindTemplatesInternalResponse = z.infer;
diff --git a/packages/trpc/server/template-router/router.ts b/packages/trpc/server/template-router/router.ts
index 00c4816f8..e7a7a9d90 100644
--- a/packages/trpc/server/template-router/router.ts
+++ b/packages/trpc/server/template-router/router.ts
@@ -27,15 +27,14 @@ import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
-import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
-import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
-import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates';
+import { mapEnvelopeToTemplateLite, mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates';
import { prisma } from '@documenso/prisma';
import type { Envelope } from '@prisma/client';
import { DocumentDataType, EnvelopeType } from '@prisma/client';
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
+import { findTemplatesInternalRoute } from './find-templates-internal';
import { getTemplatesByIdsRoute } from './get-templates-by-ids';
import {
ZBulkSendTemplateMutationSchema,
@@ -102,35 +101,15 @@ export const templateRouter = router({
// Remapping for backwards compatibility.
return {
...result,
- data: result.data.map((envelope) => {
- const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
-
- return {
- id: legacyTemplateId,
- envelopeId: envelope.id,
- type: envelope.templateType,
- visibility: envelope.visibility,
- externalId: envelope.externalId,
- title: envelope.title,
- userId: envelope.userId,
- teamId: envelope.teamId,
- authOptions: envelope.authOptions,
- createdAt: envelope.createdAt,
- updatedAt: envelope.updatedAt,
- publicTitle: envelope.publicTitle,
- publicDescription: envelope.publicDescription,
- folderId: envelope.folderId,
- useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
- team: envelope.team,
- fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
- recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
- templateMeta: envelope.documentMeta,
- directLink: envelope.directLink,
- };
- }),
+ data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
};
}),
+ /**
+ * @private
+ */
+ findTemplatesInternal: findTemplatesInternalRoute,
+
/**
* @private
*/
@@ -149,32 +128,7 @@ export const templateRouter = router({
// Remapping for backwards compatibility.
return {
...result,
- data: result.data.map((envelope) => {
- const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
-
- return {
- id: legacyTemplateId,
- envelopeId: envelope.id,
- type: envelope.templateType,
- visibility: envelope.visibility,
- externalId: envelope.externalId,
- title: envelope.title,
- userId: envelope.userId,
- teamId: envelope.teamId,
- authOptions: envelope.authOptions,
- createdAt: envelope.createdAt,
- updatedAt: envelope.updatedAt,
- publicTitle: envelope.publicTitle,
- publicDescription: envelope.publicDescription,
- folderId: envelope.folderId,
- useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
- team: envelope.team,
- fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
- recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
- templateMeta: envelope.documentMeta,
- directLink: envelope.directLink,
- };
- }),
+ data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
};
}),