mirror of
https://github.com/documenso/documenso.git
synced 2026-08-24 15:22:26 +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
|
* 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.
|
* subscriptions have no admin list pages.
|
||||||
*/
|
*/
|
||||||
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
const ADMIN_GROUP_LIST_PATHS: Partial<Record<TAdminSearchResultType, (_query: string) => string>> = {
|
||||||
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
document: (query) => `/admin/documents?term=${encodeURIComponent(query)}`,
|
||||||
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
user: (query) => `/admin/users?search=${encodeURIComponent(query)}`,
|
||||||
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
organisation: (query) => `/admin/organisations?query=${encodeURIComponent(query)}`,
|
||||||
|
recipient: (query) => `/admin/documents?term=${encodeURIComponent(`recipient:${query}`)}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseAdminSearchCategoriesOptions = {
|
export type UseAdminSearchCategoriesOptions = {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export default function AdminDocumentsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
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}
|
value={term}
|
||||||
onChange={(e) => setTerm(e.target.value)}
|
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);
|
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;
|
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 = {
|
const emptyResponse = {
|
||||||
data: [],
|
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 = {
|
termFilters = {
|
||||||
id: {
|
id: {
|
||||||
equals: query,
|
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 = {
|
termFilters = {
|
||||||
secondaryId: {
|
secondaryId: {
|
||||||
equals: query,
|
equals: query,
|
||||||
|
|||||||
Reference in New Issue
Block a user