mirror of
https://github.com/documenso/documenso.git
synced 2026-08-23 14:52:23 +10:00
feat: allow admin recipient search
This commit is contained in:
@@ -39,13 +39,14 @@ const ADMIN_GROUP_ICONS: Record<TAdminSearchResultType, LucideIcon> = {
|
||||
|
||||
/**
|
||||
* Admin list pages which support prefilling their search from the URL, used
|
||||
* for the "View all results" links on capped groups. Teams, recipients and
|
||||
* for the "View all results" links on capped groups. Teams and
|
||||
* subscriptions have no admin list pages.
|
||||
*/
|
||||
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
|
||||
};
|
||||
|
||||
export type UseAdminSearchCategoriesOptions = {
|
||||
|
||||
@@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
|
||||
<div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search by document title, team:123 or user:123`)}
|
||||
placeholder={_(msg`Search by document title, recipient:123, team:123 or user:123`)}
|
||||
value={term}
|
||||
onChange={(e) => setTerm(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -437,3 +437,50 @@ test('[ADMIN][GLOBAL_SEARCH]: over-length query skips the admin search without e
|
||||
|
||||
expect(adminSearchRequests).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][GLOBAL_SEARCH]: capped recipients group links to the admin documents page', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientPrefix = `viewall-recipient-${nanoid()}`;
|
||||
|
||||
// Seed 5 documents, each with one recipient email sharing the prefix, to
|
||||
// hit the 5 result cap on the recipients group.
|
||||
const documents = [];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
documents.push(
|
||||
await seedPendingDocument(sender, team.id, [`${recipientPrefix}-${i}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-viewall-${nanoid()}` },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
await openCommandMenu(page, ADMIN_PROMPT_PLACEHOLDER);
|
||||
|
||||
await page.getByPlaceholder(ADMIN_PROMPT_PLACEHOLDER).first().fill(recipientPrefix);
|
||||
|
||||
await expect(page.getByText('Global Recipients', { exact: true })).toBeVisible();
|
||||
|
||||
// Only the recipients group matches the prefix, so this is its link.
|
||||
const viewAllOption = page.getByRole('option').filter({ hasText: 'View all results' }).first();
|
||||
|
||||
await expect(viewAllOption.getByRole('link')).toHaveAttribute(
|
||||
'href',
|
||||
`/admin/documents?term=${encodeURIComponent(`recipient:${recipientPrefix}`)}`,
|
||||
);
|
||||
|
||||
await viewAllOption.click();
|
||||
|
||||
await page.waitForURL((url) => url.pathname === '/admin/documents');
|
||||
|
||||
// The term input is prefilled with the recipient query and the matching
|
||||
// documents are listed.
|
||||
await expect(page.getByPlaceholder(/Search by document title/)).toHaveValue(`recipient:${recipientPrefix}`);
|
||||
|
||||
for (const document of documents) {
|
||||
await expect(page.getByRole('link', { name: document.title })).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin } from '../../../fixtures/authentication';
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
type AdminFindDocumentsResult = {
|
||||
data: Array<{ envelopeId: string; title: string }>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const callAdminFindDocuments = async (page: Page, query: string) => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { query, page: 1, perPage: 20 } }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/admin.document.find?input=${inputParam}`;
|
||||
|
||||
const res = await page.context().request.get(url);
|
||||
|
||||
return {
|
||||
res,
|
||||
result: res.ok()
|
||||
? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
((await res.json()).result.data.json as AdminFindDocumentsResult)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: non-admin user is rejected with 401', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email });
|
||||
|
||||
const { res } = await callAdminFindDocuments(page, 'recipient:anything');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
// ─── recipient: prefix ───────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient email', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientEmail = `recipient-find-${nanoid()}@test.documenso.com`;
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientEmail], {
|
||||
createDocumentOptions: { title: `recipient-find-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
const otherDocument = await seedPendingDocument(sender, team.id, [`other-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-find-other-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientEmail}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
expect(result?.data.map((document) => document.envelopeId)).not.toContain(otherDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix matches by recipient name case-insensitively', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const recipientName = `recipient-name-${nanoid()}`;
|
||||
const { user: recipientUser } = await seedUser({ name: recipientName });
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [recipientUser], {
|
||||
createDocumentOptions: { title: `recipient-name-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Query the uppercased name: matching must be case-insensitive.
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipientName.toUpperCase()}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with empty value returns no results', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const emptyValueSearch = await callAdminFindDocuments(page, 'recipient:');
|
||||
|
||||
expect(emptyValueSearch.res.ok()).toBeTruthy();
|
||||
expect(emptyValueSearch.result?.data).toEqual([]);
|
||||
expect(emptyValueSearch.result?.count).toBe(0);
|
||||
|
||||
// Whitespace-only values are treated the same as empty.
|
||||
const whitespaceValueSearch = await callAdminFindDocuments(page, 'recipient: ');
|
||||
|
||||
expect(whitespaceValueSearch.res.ok()).toBeTruthy();
|
||||
expect(whitespaceValueSearch.result?.data).toEqual([]);
|
||||
expect(whitespaceValueSearch.result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with no matches returns no results', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, 'recipient:zzzz-no-such-recipient-9x7q');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data).toEqual([]);
|
||||
expect(result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with numeric value matches by exact recipient ID', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
const matchingDocument = await seedPendingDocument(sender, team.id, [`recipient-id-${nanoid()}@test.documenso.com`], {
|
||||
createDocumentOptions: { title: `recipient-id-match-${nanoid()}` },
|
||||
});
|
||||
|
||||
const recipient = matchingDocument.recipients[0];
|
||||
|
||||
// A decoy whose recipient email contains the ID as text: an exact ID lookup
|
||||
// must exclude it, while an accidental "ID or contains" match would not.
|
||||
const decoyDocument = await seedPendingDocument(
|
||||
sender,
|
||||
team.id,
|
||||
[`decoy-${recipient.id}-${nanoid()}@test.documenso.com`],
|
||||
{
|
||||
createDocumentOptions: { title: `recipient-id-decoy-${nanoid()}` },
|
||||
},
|
||||
);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${recipient.id}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.count).toBe(1);
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
expect(result?.data.map((document) => document.envelopeId)).not.toContain(decoyDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with nonexistent recipient ID returns no results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// Int4 max - 1: a valid ID-shaped number that no autoincrement recipient
|
||||
// sequence will plausibly reach, and that no other test seeds as text.
|
||||
const { res, result } = await callAdminFindDocuments(page, 'recipient:2147483646');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data).toEqual([]);
|
||||
expect(result?.count).toBe(0);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: recipient prefix with oversized number falls back to text search', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const { user: sender, team } = await seedUser();
|
||||
|
||||
// 99999999999999 exceeds Int4, so it cannot be an ID lookup: it must be
|
||||
// treated as text (and must not 500).
|
||||
const oversizedNumber = '99999999999999';
|
||||
|
||||
const matchingDocument = await seedPendingDocument(
|
||||
sender,
|
||||
team.id,
|
||||
[`${oversizedNumber}-${nanoid()}@test.documenso.com`],
|
||||
{
|
||||
createDocumentOptions: { title: `recipient-oversized-${nanoid()}` },
|
||||
},
|
||||
);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const { res, result } = await callAdminFindDocuments(page, `recipient:${oversizedNumber}`);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(result?.data.map((document) => document.envelopeId)).toContain(matchingDocument.id);
|
||||
});
|
||||
|
||||
test('[ADMIN][TRPC][FIND_DOCUMENTS]: user and team prefixes with oversized numbers return no results', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
// 99999999999999 exceeds Int4, so it can never be a valid user or team ID.
|
||||
// The ID schema must reject it so the query returns empty instead of
|
||||
// overflowing Postgres and erroring.
|
||||
const userSearch = await callAdminFindDocuments(page, 'user:99999999999999');
|
||||
|
||||
expect(userSearch.res.ok()).toBeTruthy();
|
||||
expect(userSearch.result?.data).toEqual([]);
|
||||
expect(userSearch.result?.count).toBe(0);
|
||||
|
||||
const teamSearch = await callAdminFindDocuments(page, 'team:99999999999999');
|
||||
|
||||
expect(teamSearch.res.ok()).toBeTruthy();
|
||||
expect(teamSearch.result?.data).toEqual([]);
|
||||
expect(teamSearch.result?.count).toBe(0);
|
||||
});
|
||||
@@ -10,7 +10,13 @@ export interface AdminFindDocumentsOptions {
|
||||
perPage?: number;
|
||||
}
|
||||
|
||||
const ZPositiveIntegerSchema = z.coerce.number().int().positive();
|
||||
const MAX_POSTGRES_INT = 2147483647;
|
||||
|
||||
/**
|
||||
* IDs are Postgres int4 columns: values above the range can never be valid
|
||||
* IDs and would make Prisma throw on overflow, so the schema rejects them.
|
||||
*/
|
||||
const ZPositiveIntegerSchema = z.coerce.number().int().positive().max(MAX_POSTGRES_INT);
|
||||
|
||||
const emptyResponse = {
|
||||
data: [],
|
||||
@@ -58,7 +64,41 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
}
|
||||
}
|
||||
|
||||
if (query && query?.startsWith('envelope_')) {
|
||||
if (query?.startsWith('recipient:')) {
|
||||
const recipientQuery = query.slice('recipient:'.length).trim();
|
||||
|
||||
if (recipientQuery.length === 0) {
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
// Bare numeric values are exact recipient ID lookups, consistent with the
|
||||
// user: and team: prefixes. Oversized numbers cannot be IDs and fall back
|
||||
// to the text search, mirroring the admin global search.
|
||||
const parsedRecipientId = ZPositiveIntegerSchema.safeParse(recipientQuery);
|
||||
|
||||
if (parsedRecipientId.success) {
|
||||
termFilters = {
|
||||
recipients: {
|
||||
some: {
|
||||
id: parsedRecipientId.data,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
termFilters = {
|
||||
recipients: {
|
||||
some: {
|
||||
OR: [
|
||||
{ email: { contains: recipientQuery, mode: 'insensitive' } },
|
||||
{ name: { contains: recipientQuery, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (query?.startsWith('envelope_')) {
|
||||
termFilters = {
|
||||
id: {
|
||||
equals: query,
|
||||
@@ -66,7 +106,7 @@ export const adminFindDocuments = async ({ query, page = 1, perPage = 10 }: Admi
|
||||
};
|
||||
}
|
||||
|
||||
if (query && query?.startsWith('document_')) {
|
||||
if (query?.startsWith('document_')) {
|
||||
termFilters = {
|
||||
secondaryId: {
|
||||
equals: query,
|
||||
|
||||
Reference in New Issue
Block a user