diff --git a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
index 08bc44ec5..787aa58d7 100644
--- a/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
+++ b/apps/web/src/app/(dashboard)/documents/documents-page-view.tsx
@@ -16,6 +16,7 @@ import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-documen
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
+import { DocumentSearch } from '~/components/(dashboard)/document-search/document-search';
import { PeriodSelector } from '~/components/(dashboard)/period-selector/period-selector';
import { isPeriodSelectorValue } from '~/components/(dashboard)/period-selector/types';
import { DocumentStatus } from '~/components/formatter/document-status';
@@ -25,16 +26,17 @@ import { DataTableSenderFilter } from './data-table-sender-filter';
import { EmptyDocumentState } from './empty-state';
import { UploadDocument } from './upload-document';
-export type DocumentsPageViewProps = {
+export interface DocumentsPageViewProps {
searchParams?: {
status?: ExtendedDocumentStatus;
period?: PeriodSelectorValue;
page?: string;
perPage?: string;
senderIds?: string;
+ search?: string;
};
team?: Team & { teamEmail?: TeamEmail | null } & { currentTeamMember?: { role: TeamMemberRole } };
-};
+}
export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPageViewProps) => {
const { user } = await getRequiredServerComponentSession();
@@ -44,6 +46,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 20;
const senderIds = parseToIntegerArray(searchParams.senderIds ?? '');
+ const search = searchParams.search || '';
const currentTeam = team
? { id: team.id, url: team.url, teamEmail: team.teamEmail?.email }
: undefined;
@@ -52,6 +55,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
const getStatOptions: GetStatsInput = {
user,
period,
+ search,
};
if (team) {
@@ -79,6 +83,7 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
perPage,
period,
senderIds,
+ search,
});
const getTabHref = (value: typeof status) => {
@@ -148,6 +153,9 @@ export const DocumentsPageView = async ({ searchParams = {}, team }: DocumentsPa
+
+
+
diff --git a/apps/web/src/components/(dashboard)/document-search/document-search.tsx b/apps/web/src/components/(dashboard)/document-search/document-search.tsx
new file mode 100644
index 000000000..dbfad6775
--- /dev/null
+++ b/apps/web/src/components/(dashboard)/document-search/document-search.tsx
@@ -0,0 +1,41 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import { useRouter, useSearchParams } from 'next/navigation';
+
+import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
+import { Input } from '@documenso/ui/primitives/input';
+
+export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string }) => {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const [searchTerm, setSearchTerm] = useState(initialValue);
+ const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
+
+ const handleSearch = useCallback(
+ (term: string) => {
+ const params = new URLSearchParams(searchParams?.toString() ?? '');
+ if (term) {
+ params.set('search', term);
+ } else {
+ params.delete('search');
+ }
+ router.push(`?${params.toString()}`);
+ },
+ [router, searchParams],
+ );
+
+ useEffect(() => {
+ handleSearch(searchTerm);
+ }, [debouncedSearchTerm]);
+
+ return (
+ setSearchTerm(e.target.value)}
+ />
+ );
+};
diff --git a/packages/app-tests/e2e/teams/search-documents.spec.ts b/packages/app-tests/e2e/teams/search-documents.spec.ts
new file mode 100644
index 000000000..b56e4fd00
--- /dev/null
+++ b/packages/app-tests/e2e/teams/search-documents.spec.ts
@@ -0,0 +1,249 @@
+import { expect, test } from '@playwright/test';
+
+import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
+import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
+import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+
+import { apiSignin, apiSignout } from '../fixtures/authentication';
+import { checkDocumentTabCount } from '../fixtures/documents';
+
+test('[TEAMS]: search respects team document visibility', async ({ page }) => {
+ const team = await seedTeam();
+ const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
+ const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
+ const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
+
+ await seedDocuments([
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'EVERYONE',
+ title: 'Searchable Document for Everyone',
+ },
+ },
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'MANAGER_AND_ABOVE',
+ title: 'Searchable Document for Managers',
+ },
+ },
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'ADMIN',
+ title: 'Searchable Document for Admins',
+ },
+ },
+ ]);
+
+ const testCases = [
+ { user: adminUser, visibleDocs: 3 },
+ { user: managerUser, visibleDocs: 2 },
+ { user: memberUser, visibleDocs: 1 },
+ ];
+
+ for (const { user, visibleDocs } of testCases) {
+ await apiSignin({
+ page,
+ email: user.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Searchable');
+ await page.waitForURL(/search=Searchable/);
+
+ await checkDocumentTabCount(page, 'All', visibleDocs);
+
+ await apiSignout({ page });
+ }
+});
+
+test('[TEAMS]: search does not reveal documents from other teams', async ({ page }) => {
+ const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();
+ const { team: teamB } = await seedTeamDocuments();
+
+ await seedDocuments([
+ {
+ sender: teamA.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: teamA.id,
+ visibility: 'EVERYONE',
+ title: 'Unique Team A Document',
+ },
+ },
+ {
+ sender: teamB.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: teamB.id,
+ visibility: 'EVERYONE',
+ title: 'Unique Team B Document',
+ },
+ },
+ ]);
+
+ await apiSignin({
+ page,
+ email: teamAMember.email,
+ redirectPath: `/t/${teamA.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique');
+ await page.waitForURL(/search=Unique/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(page.getByRole('link', { name: 'Unique Team A Document' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Unique Team B Document' })).not.toBeVisible();
+
+ await apiSignout({ page });
+});
+
+test('[PERSONAL]: search does not reveal team documents in personal account', async ({ page }) => {
+ const { team, teamMember2 } = await seedTeamDocuments();
+
+ await seedDocuments([
+ {
+ sender: teamMember2,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: null,
+ title: 'Personal Unique Document',
+ },
+ },
+ {
+ sender: team.owner,
+ recipients: [],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'EVERYONE',
+ title: 'Team Unique Document',
+ },
+ },
+ ]);
+
+ await apiSignin({
+ page,
+ email: teamMember2.email,
+ redirectPath: '/documents',
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique');
+ await page.waitForURL(/search=Unique/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(page.getByRole('link', { name: 'Personal Unique Document' })).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Team Unique Document' })).not.toBeVisible();
+
+ await apiSignout({ page });
+});
+
+test('[TEAMS]: search respects recipient visibility regardless of team visibility', async ({
+ page,
+}) => {
+ const team = await seedTeam();
+ const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
+
+ await seedDocuments([
+ {
+ sender: team.owner,
+ recipients: [memberUser],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'ADMIN',
+ title: 'Admin Document with Member Recipient',
+ },
+ },
+ ]);
+
+ await apiSignin({
+ page,
+ email: memberUser.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Admin Document');
+ await page.waitForURL(/search=Admin(%20|\+|\s)Document/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(
+ page.getByRole('link', { name: 'Admin Document with Member Recipient' }),
+ ).toBeVisible();
+
+ await apiSignout({ page });
+});
+
+test('[TEAMS]: search by recipient name respects visibility', async ({ page }) => {
+ const team = await seedTeam();
+ const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
+ const memberUser = await seedTeamMember({
+ teamId: team.id,
+ role: TeamMemberRole.MEMBER,
+ name: 'Team Member',
+ });
+
+ const uniqueRecipient = await seedUser();
+
+ await seedDocuments([
+ {
+ sender: team.owner,
+ recipients: [uniqueRecipient],
+ type: DocumentStatus.COMPLETED,
+ documentOptions: {
+ teamId: team.id,
+ visibility: 'ADMIN',
+ title: 'Admin Document for Unique Recipient',
+ },
+ },
+ ]);
+
+ // Admin should see the document when searching by recipient name
+ await apiSignin({
+ page,
+ email: adminUser.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
+ await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
+
+ await checkDocumentTabCount(page, 'All', 1);
+ await expect(
+ page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
+ ).toBeVisible();
+
+ await apiSignout({ page });
+
+ // Member should not see the document when searching by recipient name
+ await apiSignin({
+ page,
+ email: memberUser.email,
+ redirectPath: `/t/${team.url}/documents`,
+ });
+
+ await page.getByPlaceholder('Search documents...').fill('Unique Recipient');
+ await page.waitForURL(/search=Unique(%20|\+|\s)Recipient/);
+
+ await checkDocumentTabCount(page, 'All', 0);
+ await expect(
+ page.getByRole('link', { name: 'Admin Document for Unique Recipient' }),
+ ).not.toBeVisible();
+
+ await apiSignout({ page });
+});
diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts
index 03aeacc86..2495973f2 100644
--- a/packages/lib/server-only/document/find-documents.ts
+++ b/packages/lib/server-only/document/find-documents.ts
@@ -25,6 +25,7 @@ export type FindDocumentsOptions = {
};
period?: PeriodSelectorValue;
senderIds?: number[];
+ search?: string;
};
export const findDocuments = async ({
@@ -37,6 +38,7 @@ export const findDocuments = async ({
orderBy,
period,
senderIds,
+ search,
}: FindDocumentsOptions) => {
const { user, team } = await prisma.$transaction(async (tx) => {
const user = await tx.user.findFirstOrThrow({
@@ -92,6 +94,14 @@ export const findDocuments = async ({
})
.otherwise(() => undefined);
+ const searchFilter: Prisma.DocumentWhereInput = {
+ OR: [
+ { title: { contains: search, mode: 'insensitive' } },
+ { Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
+ { Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
+ ],
+ };
+
const visibilityFilters = [
match(teamMemberRole)
.with(TeamMemberRole.ADMIN, () => ({
@@ -188,7 +198,7 @@ export const findDocuments = async ({
}
const whereClause: Prisma.DocumentWhereInput = {
- AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }],
+ AND: [{ ...termFilters }, { ...filters }, { ...deletedFilter }, { ...searchFilter }],
};
if (period) {
diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts
index 60c3dcea8..9ea58c828 100644
--- a/packages/lib/server-only/document/get-stats.ts
+++ b/packages/lib/server-only/document/get-stats.ts
@@ -15,9 +15,10 @@ export type GetStatsInput = {
user: User;
team?: Omit;
period?: PeriodSelectorValue;
+ search?: string;
};
-export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
+export const getStats = async ({ user, period, search, ...options }: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
@@ -31,8 +32,14 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
- ? getTeamCounts({ ...options.team, createdAt, currentUserEmail: user.email, userId: user.id })
- : getCounts({ user, createdAt }));
+ ? getTeamCounts({
+ ...options.team,
+ createdAt,
+ currentUserEmail: user.email,
+ userId: user.id,
+ search,
+ })
+ : getCounts({ user, createdAt, search }));
const stats: Record = {
[ExtendedDocumentStatus.DRAFT]: 0,
@@ -72,9 +79,18 @@ export const getStats = async ({ user, period, ...options }: GetStatsInput) => {
type GetCountsOption = {
user: User;
createdAt: Prisma.DocumentWhereInput['createdAt'];
+ search?: string;
};
-const getCounts = async ({ user, createdAt }: GetCountsOption) => {
+const getCounts = async ({ user, createdAt, search }: GetCountsOption) => {
+ const searchFilter: Prisma.DocumentWhereInput = {
+ OR: [
+ { title: { contains: search, mode: 'insensitive' } },
+ { Recipient: { some: { name: { contains: search, mode: 'insensitive' } } } },
+ { Recipient: { some: { email: { contains: search, mode: 'insensitive' } } } },
+ ],
+ };
+
return Promise.all([
// Owner counts.
prisma.document.groupBy({
@@ -87,6 +103,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
createdAt,
teamId: null,
deletedAt: null,
+ AND: [searchFilter],
},
}),
// Not signed counts.
@@ -105,6 +122,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
},
},
createdAt,
+ AND: [searchFilter],
},
}),
// Has signed counts.
@@ -142,6 +160,7 @@ const getCounts = async ({ user, createdAt }: GetCountsOption) => {
},
},
],
+ AND: [searchFilter],
},
}),
]);
@@ -155,6 +174,7 @@ type GetTeamCountsOption = {
userId: number;
createdAt: Prisma.DocumentWhereInput['createdAt'];
currentTeamMemberRole?: TeamMemberRole;
+ search?: string;
};
const getTeamCounts = async (options: GetTeamCountsOption) => {
@@ -169,6 +189,14 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
}
: undefined;
+ const searchFilter: Prisma.DocumentWhereInput = {
+ OR: [
+ { title: { contains: options.search, mode: 'insensitive' } },
+ { Recipient: { some: { name: { contains: options.search, mode: 'insensitive' } } } },
+ { Recipient: { some: { email: { contains: options.search, mode: 'insensitive' } } } },
+ ],
+ };
+
let ownerCountsWhereInput: Prisma.DocumentWhereInput = {
userId: userIdWhereClause,
createdAt,
@@ -220,6 +248,7 @@ const getTeamCounts = async (options: GetTeamCountsOption) => {
},
},
],
+ ...searchFilter,
};
if (teamEmail) {
diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po
index aaa9fe2f7..1822b62d6 100644
--- a/packages/lib/translations/de/web.po
+++ b/packages/lib/translations/de/web.po
@@ -1316,7 +1316,7 @@ msgstr "Dokument wird dauerhaft gelöscht"
#: apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx:109
#: apps/web/src/app/(dashboard)/documents/[id]/loading.tsx:16
#: apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx:15
-#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:114
+#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:119
#: apps/web/src/app/(profile)/p/[url]/page.tsx:166
#: apps/web/src/app/not-found.tsx:21
#: apps/web/src/components/(dashboard)/common/command-menu.tsx:205
diff --git a/packages/lib/translations/en/web.po b/packages/lib/translations/en/web.po
index 21bc4f1a1..be7276ed4 100644
--- a/packages/lib/translations/en/web.po
+++ b/packages/lib/translations/en/web.po
@@ -1311,7 +1311,7 @@ msgstr "Document will be permanently deleted"
#: apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx:109
#: apps/web/src/app/(dashboard)/documents/[id]/loading.tsx:16
#: apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx:15
-#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:114
+#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:119
#: apps/web/src/app/(profile)/p/[url]/page.tsx:166
#: apps/web/src/app/not-found.tsx:21
#: apps/web/src/components/(dashboard)/common/command-menu.tsx:205
diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po
index 5b3108a09..9fac1a10d 100644
--- a/packages/lib/translations/fr/web.po
+++ b/packages/lib/translations/fr/web.po
@@ -1316,7 +1316,7 @@ msgstr "Le document sera supprimé de manière permanente"
#: apps/web/src/app/(dashboard)/documents/[id]/edit/document-edit-page-view.tsx:109
#: apps/web/src/app/(dashboard)/documents/[id]/loading.tsx:16
#: apps/web/src/app/(dashboard)/documents/[id]/sent/page.tsx:15
-#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:114
+#: apps/web/src/app/(dashboard)/documents/documents-page-view.tsx:119
#: apps/web/src/app/(profile)/p/[url]/page.tsx:166
#: apps/web/src/app/not-found.tsx:21
#: apps/web/src/components/(dashboard)/common/command-menu.tsx:205
diff --git a/packages/prisma/seed/teams.ts b/packages/prisma/seed/teams.ts
index 65cf3b600..28e1eb390 100644
--- a/packages/prisma/seed/teams.ts
+++ b/packages/prisma/seed/teams.ts
@@ -105,13 +105,15 @@ export const unseedTeam = async (teamUrl: string) => {
type SeedTeamMemberOptions = {
teamId: number;
role?: TeamMemberRole;
+ name?: string;
};
export const seedTeamMember = async ({
teamId,
+ name,
role = TeamMemberRole.ADMIN,
}: SeedTeamMemberOptions) => {
- const user = await seedUser();
+ const user = await seedUser({ name });
await prisma.teamMember.create({
data: {
diff --git a/packages/prisma/seed/users.ts b/packages/prisma/seed/users.ts
index 8583473bb..73f1cb157 100644
--- a/packages/prisma/seed/users.ts
+++ b/packages/prisma/seed/users.ts
@@ -21,8 +21,13 @@ export const seedUser = async ({
password = 'password',
verified = true,
}: SeedUserOptions = {}) => {
- if (!name) {
+ let url = name;
+
+ if (name) {
+ url = nanoid();
+ } else {
name = nanoid();
+ url = name;
}
if (!email) {
@@ -35,7 +40,7 @@ export const seedUser = async ({
email,
password: hashSync(password),
emailVerified: verified ? new Date() : undefined,
- url: name,
+ url,
},
});
};