diff --git a/apps/remix/app/components/tables/documents-table-status-filter.tsx b/apps/remix/app/components/tables/documents-table-status-filter.tsx
index 25abc1164..0d5f23ddc 100644
--- a/apps/remix/app/components/tables/documents-table-status-filter.tsx
+++ b/apps/remix/app/components/tables/documents-table-status-filter.tsx
@@ -1,4 +1,4 @@
-import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
+import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { STATS_COUNT_CAP } from '@documenso/lib/constants/document';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/find-documents-internal.types';
@@ -14,13 +14,26 @@ import { FilterPill } from '~/components/general/filter-pill';
import { documentsSearchParams } from '~/utils/documents-search-params';
type DocumentsTableStatusFilterProps = {
- stats: TFindDocumentsInternalResponse['stats'];
+ /**
+ * Per-status document counts, shown next to each option. When omitted no
+ * counts are rendered.
+ */
+ stats?: TFindDocumentsInternalResponse['stats'];
+
+ /**
+ * The statuses available for selection. Defaults to every status that
+ * makes sense for the documents page.
+ */
+ statuses?: ExtendedDocumentStatus[];
};
-export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilterProps) => {
+export const DocumentsTableStatusFilter = ({
+ stats,
+ statuses = SELECTABLE_STATUSES,
+}: DocumentsTableStatusFilterProps) => {
const { _ } = useLingui();
- const organisation = useCurrentOrganisation();
+ const organisation = useOptionalCurrentOrganisation();
const [{ status }, setSearchParams] = useQueryStates(
{
@@ -32,14 +45,14 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
const selectableStatuses = useMemo(
() =>
- SELECTABLE_STATUSES.filter((value) => {
- if (organisation.type === OrganisationType.PERSONAL) {
+ statuses.filter((value) => {
+ if (organisation?.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
}),
- [organisation.type],
+ [organisation?.type, statuses],
);
const selectedStatus = useMemo(
@@ -65,20 +78,22 @@ export const DocumentsTableStatusFilter = ({ stats }: DocumentsTableStatusFilter
options={selectableStatuses.map((value) => ({
value,
label: ,
- trailing: formatStatsCount(stats[value]),
+ trailing: stats ? formatStatsCount(stats[value]) : undefined,
}))}
testId="documents-table-status-filter"
/>
{/* Visually hidden document counts, for screen readers and tests. */}
-
- {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
-
- {_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
- {stats[value]}
-
- ))}
-
+ {stats && (
+
+ {[...selectableStatuses, ExtendedDocumentStatus.ALL].map((value) => (
+
+ {_(FRIENDLY_STATUS_MAP[value].label)}:{' '}
+ {stats[value]}
+
+ ))}
+
+ )}
>
);
};
diff --git a/apps/remix/app/components/tables/inbox-table.tsx b/apps/remix/app/components/tables/inbox-table.tsx
index f958da66d..0aec5bf22 100644
--- a/apps/remix/app/components/tables/inbox-table.tsx
+++ b/apps/remix/app/components/tables/inbox-table.tsx
@@ -16,22 +16,17 @@ import { Trans } from '@lingui/react/macro';
import { DocumentStatus as DocumentStatusEnum, RecipientRole, SigningStatus } from '@prisma/client';
import { CheckCircleIcon, DownloadIcon, EyeIcon, Loader, PencilIcon } from 'lucide-react';
import { DateTime } from 'luxon';
+import { useQueryStates } from 'nuqs';
import { useMemo, useTransition } from 'react';
-import { useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { DocumentStatus } from '~/components/general/document/document-status';
import { useOptionalCurrentTeam } from '~/providers/team';
+import { inboxSearchParams, resolveInboxStatus } from '~/utils/inbox-search-params';
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
-export type DocumentsTableProps = {
- data?: TFindInboxResponse;
- isLoading?: boolean;
- isLoadingError?: boolean;
-};
-
type DocumentsTableRow = TFindInboxResponse['data'][number];
export const InboxTable = () => {
@@ -40,17 +35,24 @@ export const InboxTable = () => {
const team = useOptionalCurrentTeam();
const [isPending, startTransition] = useTransition();
- const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
- const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
- const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
+ const [findInboxSearchParams] = useQueryStates(inboxSearchParams, {
+ history: 'push',
+ });
+
+ const status = resolveInboxStatus(findInboxSearchParams.status);
+ const query = findInboxSearchParams.query ?? '';
const { data, isLoading, isLoadingError } = trpc.document.inbox.find.useQuery({
- page: page || 1,
- perPage: perPage || 10,
+ page: Math.max(findInboxSearchParams.page ?? 1, 1),
+ perPage: Math.min(Math.max(findInboxSearchParams.perPage ?? 10, 1), 100),
+ query: query || undefined,
+ status,
});
+ const hasSearchQuery = query.trim().length > 0;
+
const columns = useMemo(() => {
return [
{
@@ -123,7 +125,20 @@ export const InboxTable = () => {
emptyState={
- Documents that require your attention will appear here
+ {match({ hasSearchQuery, status })
+ .with({ hasSearchQuery: true }, () => No documents match your search)
+ .with({ status: DocumentStatusEnum.COMPLETED }, () => (
+ Documents that you have completed will appear here
+ ))
+ .with({ status: DocumentStatusEnum.REJECTED }, () => (
+ Documents that have been rejected will appear here
+ ))
+ .with({ status: DocumentStatusEnum.CANCELLED }, () => (
+ Documents that have been cancelled will appear here
+ ))
+ .otherwise(() => (
+ Documents that require your attention will appear here
+ ))}
}
diff --git a/apps/remix/app/routes/_authenticated+/inbox.tsx b/apps/remix/app/routes/_authenticated+/inbox.tsx
index 3ec3b70b7..fe1b49b2e 100644
--- a/apps/remix/app/routes/_authenticated+/inbox.tsx
+++ b/apps/remix/app/routes/_authenticated+/inbox.tsx
@@ -2,8 +2,11 @@ import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { InboxIcon } from 'lucide-react';
+import { DocumentSearch } from '~/components/general/document/document-search';
import { OrganisationInvitations } from '~/components/general/organisations/organisation-invitations';
+import { DocumentsTableStatusFilter } from '~/components/tables/documents-table-status-filter';
import { InboxTable } from '~/components/tables/inbox-table';
+import { INBOX_SELECTABLE_STATUSES } from '~/utils/inbox-search-params';
import { appMetaTags } from '~/utils/meta';
export function meta() {
@@ -26,6 +29,14 @@ export default function InboxPage() {
+
+
);
diff --git a/apps/remix/app/utils/inbox-search-params.ts b/apps/remix/app/utils/inbox-search-params.ts
new file mode 100644
index 000000000..e2c900cbe
--- /dev/null
+++ b/apps/remix/app/utils/inbox-search-params.ts
@@ -0,0 +1,33 @@
+import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
+import { INBOX_STATUSES, type TInboxStatus } from '@documenso/trpc/server/document-router/find-inbox.types';
+
+import { documentsSearchParams } from './documents-search-params';
+
+/**
+ * The statuses that can be selected from the inbox status filter.
+ */
+export const INBOX_SELECTABLE_STATUSES: ExtendedDocumentStatus[] = [...INBOX_STATUSES];
+
+/**
+ * Shared nuqs parsers for the inbox page URL state.
+ *
+ * Reuses the documents parsers so the shared filter components
+ * (`DocumentSearch`, `DocumentsTableStatusFilter`) read and write the same
+ * params on both pages.
+ */
+export const inboxSearchParams = {
+ status: documentsSearchParams.status,
+ page: documentsSearchParams.page,
+ perPage: documentsSearchParams.perPage,
+ query: documentsSearchParams.query,
+};
+
+/**
+ * Narrows the URL `status` param to a status supported by the inbox.
+ *
+ * Returns `undefined` when it is missing or not selectable, which shows every
+ * non-draft document.
+ */
+export const resolveInboxStatus = (status: ExtendedDocumentStatus | null): TInboxStatus | undefined => {
+ return INBOX_STATUSES.find((value) => value === status);
+};
diff --git a/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts b/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts
new file mode 100644
index 000000000..b5c47177e
--- /dev/null
+++ b/packages/app-tests/e2e/api/trpc/find-inbox.spec.ts
@@ -0,0 +1,423 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { prisma } from '@documenso/prisma';
+import {
+ seedCancelledDocument,
+ seedCompletedDocument,
+ seedDraftDocument,
+ seedPendingDocument,
+} from '@documenso/prisma/seed/documents';
+import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+import { DocumentStatus, EnvelopeType, RecipientRole, TeamMemberRole } from '@prisma/client';
+
+import { apiSignin, apiSignout } from '../../fixtures/authentication';
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+test.describe.configure({
+ mode: 'parallel',
+});
+
+type InboxFindInput = {
+ query?: string;
+ status?: string;
+ page?: number;
+ perPage?: number;
+};
+
+type InboxFindDocument = {
+ envelopeId: string;
+ title: string;
+ status: string;
+ recipients: Array<{ email: string; token: string }>;
+};
+
+/**
+ * Calls `document.inbox.find` directly, bypassing any UI level restrictions so
+ * we can assert the server rejects or ignores hostile input on its own.
+ */
+const trpcInboxFind = async (page: Page, input: InboxFindInput) => {
+ const inputParam = encodeURIComponent(JSON.stringify({ json: input }));
+ const url = `${WEBAPP_BASE_URL}/api/trpc/document.inbox.find?input=${inputParam}`;
+
+ const res = await page.context().request.get(url);
+
+ return {
+ res,
+ data: res.ok()
+ ? // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
+ ((await res.json()).result.data.json as { data: InboxFindDocument[]; count: number })
+ : null,
+ };
+};
+
+const titlesOf = (data: { data: InboxFindDocument[] } | null) => (data?.data ?? []).map((doc) => doc.title);
+
+// ─── Recipient scoping ───────────────────────────────────────────────────────
+
+test.describe('Inbox Find - Recipient Scoping', () => {
+ test('should not return documents the user is not a recipient of, even when searched by title', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: victim } = await seedUser();
+ const { user: attacker } = await seedUser();
+
+ await seedPendingDocument(sender, senderTeam.id, [victim], {
+ createDocumentOptions: { title: 'Confidential Merger Agreement' },
+ });
+
+ await seedCompletedDocument(sender, senderTeam.id, [victim], {
+ createDocumentOptions: { title: 'Confidential Severance Package' },
+ });
+
+ // Positive control: the actual recipient can find them.
+ await apiSignin({ page, email: victim.email });
+
+ const victimPending = await trpcInboxFind(page, { query: 'Confidential', status: 'PENDING' });
+ expect(titlesOf(victimPending.data)).toEqual(['Confidential Merger Agreement']);
+
+ const victimCompleted = await trpcInboxFind(page, { query: 'Confidential', status: 'COMPLETED' });
+ expect(titlesOf(victimCompleted.data)).toEqual(['Confidential Severance Package']);
+
+ await apiSignout({ page });
+
+ // The attacker knows the exact title but is not a recipient.
+ await apiSignin({ page, email: attacker.email });
+
+ for (const status of ['PENDING', 'COMPLETED', undefined]) {
+ const { res, data } = await trpcInboxFind(page, { query: 'Confidential', status });
+
+ expect(res.ok()).toBeTruthy();
+ expect(data?.count).toBe(0);
+ expect(titlesOf(data)).toEqual([]);
+ }
+
+ await apiSignout({ page });
+ });
+
+ test('should not return documents where the user is only a CC recipient', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'CC Only Contract' },
+ });
+
+ await prisma.recipient.updateMany({
+ where: { envelopeId: ccDocument.id, email: recipient.email },
+ data: { role: RecipientRole.CC },
+ });
+
+ // Positive control on the same account.
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Signer Contract' },
+ });
+
+ await apiSignin({ page, email: recipient.email });
+
+ const unfiltered = await trpcInboxFind(page, {});
+ expect(titlesOf(unfiltered.data)).toEqual(['Signer Contract']);
+
+ const searched = await trpcInboxFind(page, { query: 'CC Only' });
+ expect(titlesOf(searched.data)).toEqual([]);
+
+ await apiSignout({ page });
+ });
+
+ test('should not expose team documents to team members who are not recipients', async ({ page }) => {
+ const { team, owner } = await seedTeam();
+ const { user: outsideRecipient } = await seedUser();
+
+ // A team admin can see this document on the team documents page, but the
+ // inbox is strictly recipient scoped.
+ const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
+
+ await seedPendingDocument(owner, team.id, [outsideRecipient], {
+ createDocumentOptions: { title: 'Team Payroll Summary' },
+ });
+
+ await seedCompletedDocument(owner, team.id, [outsideRecipient], {
+ createDocumentOptions: { title: 'Team Board Minutes' },
+ });
+
+ await apiSignin({ page, email: teamAdmin.email });
+
+ const pending = await trpcInboxFind(page, { query: 'Team', status: 'PENDING' });
+ expect(titlesOf(pending.data)).toEqual([]);
+
+ const completed = await trpcInboxFind(page, { query: 'Team', status: 'COMPLETED' });
+ expect(titlesOf(completed.data)).toEqual([]);
+
+ await apiSignout({ page });
+
+ // The document owner is also not a recipient, so it should not be in their inbox either.
+ await apiSignin({ page, email: owner.email });
+
+ const ownerResult = await trpcInboxFind(page, { query: 'Team' });
+ expect(titlesOf(ownerResult.data)).toEqual([]);
+
+ await apiSignout({ page });
+ });
+
+ test('should mask signing tokens of other recipients', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+ const { user: otherRecipient } = await seedUser();
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient, otherRecipient], {
+ createDocumentOptions: { title: 'Shared Token Document' },
+ });
+
+ await apiSignin({ page, email: recipient.email });
+
+ const { data } = await trpcInboxFind(page, { query: 'Shared Token', status: 'PENDING' });
+
+ expect(data?.data).toHaveLength(1);
+
+ const document = data?.data[0];
+
+ const ownRecipient = document?.recipients.find((r) => r.email === recipient.email);
+ const foreignRecipient = document?.recipients.find((r) => r.email === otherRecipient.email);
+
+ expect(ownRecipient?.token).toBeTruthy();
+ expect(foreignRecipient?.token).toBe('');
+
+ await apiSignout({ page });
+ });
+});
+
+// ─── Search hardening ────────────────────────────────────────────────────────
+
+test.describe('Inbox Find - Search Hardening', () => {
+ test('should keep wildcard searches scoped to the recipient inbox', async ({ page }) => {
+ // SQL LIKE wildcards ("%" and "_") are intentionally passed through so
+ // users can do advanced searches. That must only ever widen the title
+ // match, never the recipient scoping.
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: victim } = await seedUser();
+ const { user: attacker } = await seedUser();
+
+ await seedPendingDocument(sender, senderTeam.id, [victim], {
+ createDocumentOptions: { title: 'Victim Alpha Report' },
+ });
+
+ await seedCompletedDocument(sender, senderTeam.id, [victim], {
+ createDocumentOptions: { title: 'Victim Beta Report' },
+ });
+
+ // The attacker has one document of their own so we can prove wildcards
+ // return their inbox and nothing more.
+ await seedPendingDocument(sender, senderTeam.id, [attacker], {
+ createDocumentOptions: { title: 'Attacker Own Report' },
+ });
+
+ const wildcardQueries = ['%', '_', '%%%', '%Report%', 'Victim%', 'Victim _lpha%', '\\', '%victim%'];
+
+ // Positive control: wildcards work for the actual recipient.
+ await apiSignin({ page, email: victim.email });
+
+ const victimAll = await trpcInboxFind(page, { query: '%' });
+ expect(titlesOf(victimAll.data).sort()).toEqual(['Victim Alpha Report', 'Victim Beta Report']);
+
+ const victimPattern = await trpcInboxFind(page, { query: 'Victim _lpha%' });
+ expect(titlesOf(victimPattern.data)).toEqual(['Victim Alpha Report']);
+
+ await apiSignout({ page });
+
+ // The attacker gets exactly their own inbox for every wildcard, never the victim's.
+ await apiSignin({ page, email: attacker.email });
+
+ for (const query of wildcardQueries) {
+ const { res, data } = await trpcInboxFind(page, { query });
+
+ expect(res.ok(), `query "${query}"`).toBeTruthy();
+
+ const titles = titlesOf(data);
+
+ expect(titles, `query "${query}"`).not.toContain('Victim Alpha Report');
+ expect(titles, `query "${query}"`).not.toContain('Victim Beta Report');
+ expect(
+ titles.every((title) => title === 'Attacker Own Report'),
+ `query "${query}"`,
+ ).toBe(true);
+ }
+
+ // Wildcards combined with the status filter still cannot escape the scope.
+ for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED']) {
+ const { data } = await trpcInboxFind(page, { query: '%', status });
+
+ expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Alpha Report');
+ expect(titlesOf(data), `status "${status}"`).not.toContain('Victim Beta Report');
+ }
+
+ await apiSignout({ page });
+ });
+
+ test('should only match against the document title', async ({ page }) => {
+ // Explicit names so the negative queries below are deterministic.
+ const { user: sender, team: senderTeam } = await seedUser({ name: 'Sender Person' });
+ const { user: recipient } = await seedUser({ name: 'Recipient Person' });
+
+ await seedPendingDocument(sender, senderTeam.id, ['zebra-person@test.documenso.com', recipient], {
+ createDocumentOptions: {
+ title: 'Plain Title',
+ externalId: 'ext-hidden-identifier',
+ },
+ });
+
+ await apiSignin({ page, email: recipient.email });
+
+ // Positive control.
+ const byTitle = await trpcInboxFind(page, { query: 'Plain' });
+ expect(titlesOf(byTitle.data)).toEqual(['Plain Title']);
+
+ // External IDs, sender details and other recipients must not be probeable
+ // through the inbox search.
+ for (const query of ['ext-hidden', sender.email, 'Sender Person', 'zebra-person']) {
+ const { data } = await trpcInboxFind(page, { query });
+
+ expect(titlesOf(data), `query "${query}"`).toEqual([]);
+ }
+
+ await apiSignout({ page });
+ });
+
+ test('should not surface deleted, draft or template envelopes through search', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Hidden Deleted Document' },
+ });
+
+ await prisma.envelope.update({
+ where: { id: deletedDocument.id },
+ data: { deletedAt: new Date() },
+ });
+
+ await seedDraftDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Hidden Draft Document' },
+ });
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Hidden Template Envelope', type: EnvelopeType.TEMPLATE },
+ });
+
+ // Positive control.
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Hidden Visible Document' },
+ });
+
+ await apiSignin({ page, email: recipient.email });
+
+ const unfiltered = await trpcInboxFind(page, { query: 'Hidden' });
+ expect(titlesOf(unfiltered.data)).toEqual(['Hidden Visible Document']);
+
+ for (const query of ['Hidden Deleted', 'Hidden Draft', 'Hidden Template']) {
+ const { data } = await trpcInboxFind(page, { query });
+ expect(titlesOf(data)).toEqual([]);
+ }
+
+ await apiSignout({ page });
+ });
+});
+
+// ─── Status filter hardening ─────────────────────────────────────────────────
+
+test.describe('Inbox Find - Status Filter Hardening', () => {
+ test('should reject draft and virtual status values', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ // A recipient on a draft must never be able to pull it out via the status filter.
+ await seedDraftDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Unsent Draft Document' },
+ });
+
+ await apiSignin({ page, email: recipient.email });
+
+ for (const status of ['DRAFT', 'EXPIRED', 'INBOX', 'ALL', 'pending', 'draft', '']) {
+ const { res, data } = await trpcInboxFind(page, { status });
+
+ expect(res.status(), `status "${status}" should be rejected`).toBe(400);
+ expect(data).toBeNull();
+ }
+
+ // Sanity check that the valid filters, and the unfiltered view, never include the draft.
+ for (const status of ['PENDING', 'COMPLETED', 'REJECTED', 'CANCELLED', undefined]) {
+ const { res, data } = await trpcInboxFind(page, { status });
+
+ expect(res.ok(), `status "${status}" should be accepted`).toBeTruthy();
+ expect(titlesOf(data)).toEqual([]);
+ }
+
+ await apiSignout({ page });
+ });
+
+ test('should scope each status filter to exactly that status', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Scoped Pending Document' },
+ });
+
+ await seedCompletedDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Scoped Completed Document' },
+ });
+
+ await seedCancelledDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Scoped Cancelled Document' },
+ });
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Scoped Rejected Document', status: DocumentStatus.REJECTED },
+ });
+
+ await apiSignin({ page, email: recipient.email });
+
+ const expectations = [
+ { status: 'PENDING', expected: ['Scoped Pending Document'] },
+ { status: 'COMPLETED', expected: ['Scoped Completed Document'] },
+ { status: 'CANCELLED', expected: ['Scoped Cancelled Document'] },
+ { status: 'REJECTED', expected: ['Scoped Rejected Document'] },
+ ];
+
+ for (const { status, expected } of expectations) {
+ const { data } = await trpcInboxFind(page, { query: 'Scoped', status });
+
+ expect(titlesOf(data), `status "${status}"`).toEqual(expected);
+ }
+
+ await apiSignout({ page });
+ });
+
+ test('should reject pagination values outside of the allowed range', async ({ page }) => {
+ const { user } = await seedUser();
+
+ await apiSignin({ page, email: user.email });
+
+ const tooManyPerPage = await trpcInboxFind(page, { perPage: 101 });
+ expect(tooManyPerPage.res.status()).toBe(400);
+
+ const zeroPerPage = await trpcInboxFind(page, { perPage: 0 });
+ expect(zeroPerPage.res.status()).toBe(400);
+
+ const zeroPage = await trpcInboxFind(page, { page: 0 });
+ expect(zeroPage.res.status()).toBe(400);
+
+ await apiSignout({ page });
+ });
+});
+
+// ─── Authentication ──────────────────────────────────────────────────────────
+
+test.describe('Inbox Find - Authentication', () => {
+ test('should reject unauthenticated requests', async ({ page }) => {
+ const { res } = await trpcInboxFind(page, { query: 'anything', status: 'PENDING' });
+
+ expect(res.ok()).toBeFalsy();
+ expect(res.status()).toBe(401);
+ });
+});
diff --git a/packages/app-tests/e2e/documents/inbox.spec.ts b/packages/app-tests/e2e/documents/inbox.spec.ts
new file mode 100644
index 000000000..e88027028
--- /dev/null
+++ b/packages/app-tests/e2e/documents/inbox.spec.ts
@@ -0,0 +1,335 @@
+import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
+import { prisma } from '@documenso/prisma';
+import {
+ seedCancelledDocument,
+ seedCompletedDocument,
+ seedDraftDocument,
+ seedPendingDocument,
+} from '@documenso/prisma/seed/documents';
+import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
+import { seedUser } from '@documenso/prisma/seed/users';
+import type { Page } from '@playwright/test';
+import { expect, test } from '@playwright/test';
+import { DocumentStatus, RecipientRole, TeamMemberRole } from '@prisma/client';
+
+import { apiSignin } from '../fixtures/authentication';
+
+test.describe.configure({
+ mode: 'parallel',
+});
+
+const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+
+const DEFAULT_EMPTY_STATE = 'Documents that require your attention will appear here';
+const COMPLETED_EMPTY_STATE = 'Documents that you have completed will appear here';
+const SEARCH_EMPTY_STATE = 'No documents match your search';
+
+const inboxRow = (page: Page, title: string) => page.getByRole('row').filter({ hasText: title });
+
+const searchInbox = async (page: Page, query: string) => {
+ await page.getByPlaceholder('Search documents...').fill(query);
+
+ // An empty search removes the param entirely.
+ await page.waitForURL((url) => (url.searchParams.get('query') ?? '') === query);
+};
+
+// Rendered labels come from the compiled English catalog, which uses the US
+// spelling "Canceled" for the `Cancelled` source string.
+const INBOX_STATUS_LABELS = {
+ [DocumentStatus.PENDING]: 'Pending',
+ [DocumentStatus.COMPLETED]: 'Completed',
+ [DocumentStatus.REJECTED]: 'Rejected',
+ [DocumentStatus.CANCELLED]: 'Canceled',
+} as const;
+
+type InboxStatus = keyof typeof INBOX_STATUS_LABELS;
+
+const selectInboxStatus = async (page: Page, status: InboxStatus) => {
+ await page.getByTestId('documents-table-status-filter').click();
+ await page.getByRole('option', { name: INBOX_STATUS_LABELS[status], exact: true }).click();
+ await page.waitForURL((url) => url.searchParams.get('status') === status);
+};
+
+// ─── Behaviour ───────────────────────────────────────────────────────────────
+
+test.describe('Inbox - Search & Status Filter', () => {
+ test('should show every non-draft document by default', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Inbox Pending Document' },
+ });
+
+ await seedCompletedDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Inbox Completed Document' },
+ });
+
+ await seedCancelledDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Inbox Cancelled Document' },
+ });
+
+ await seedDraftDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Inbox Draft Document' },
+ });
+
+ await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
+
+ // No status selected by default.
+ expect(new URL(page.url()).searchParams.get('status')).toBeNull();
+ await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
+
+ await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible();
+
+ await selectInboxStatus(page, DocumentStatus.COMPLETED);
+
+ await expect(page.getByTestId('documents-table-status-filter')).toContainText('Completed');
+ await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible();
+ await expect(inboxRow(page, 'Inbox Cancelled Document')).not.toBeVisible();
+
+ await selectInboxStatus(page, DocumentStatus.CANCELLED);
+
+ await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Pending Document')).not.toBeVisible();
+ await expect(inboxRow(page, 'Inbox Completed Document')).not.toBeVisible();
+
+ // Clearing the filter returns to every non-draft document.
+ await page.getByTestId('documents-table-status-filter').click();
+ await page.getByRole('option', { name: 'Clear' }).click();
+ await page.waitForURL((url) => url.searchParams.get('status') === null);
+
+ await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
+ await expect(inboxRow(page, 'Inbox Pending Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Completed Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Cancelled Document')).toBeVisible();
+ await expect(inboxRow(page, 'Inbox Draft Document')).not.toBeVisible();
+ });
+
+ test('should offer every status except draft', async ({ page }) => {
+ const { user } = await seedUser();
+
+ await apiSignin({ page, email: user.email, redirectPath: '/inbox' });
+
+ await page.getByTestId('documents-table-status-filter').click();
+
+ for (const visibleStatus of Object.values(INBOX_STATUS_LABELS)) {
+ await expect(page.getByRole('option', { name: visibleStatus, exact: true })).toBeVisible();
+ }
+
+ for (const hiddenStatus of ['Draft', 'Inbox', 'All', 'Expired']) {
+ await expect(page.getByRole('option', { name: hiddenStatus, exact: true })).not.toBeVisible();
+ }
+ });
+
+ test('should filter documents by title', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Alpha Agreement' },
+ });
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Beta Agreement' },
+ });
+
+ await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
+
+ await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible();
+ await expect(inboxRow(page, 'Beta Agreement')).toBeVisible();
+
+ await searchInbox(page, 'alpha');
+
+ await expect(inboxRow(page, 'Alpha Agreement')).toBeVisible();
+ await expect(inboxRow(page, 'Beta Agreement')).not.toBeVisible();
+
+ await searchInbox(page, 'Gamma');
+
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+
+ // Search is combined with the status filter.
+ await selectInboxStatus(page, DocumentStatus.COMPLETED);
+ await searchInbox(page, 'Agreement');
+
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(inboxRow(page, 'Alpha Agreement')).not.toBeVisible();
+
+ // Clearing the search shows the status specific empty state.
+ await searchInbox(page, '');
+
+ await expect(page.getByText(COMPLETED_EMPTY_STATE)).toBeVisible();
+ });
+});
+
+// ─── Adversarial ─────────────────────────────────────────────────────────────
+
+test.describe('Inbox - Adversarial Access', () => {
+ test('should not leak another user documents through search', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: victim } = await seedUser();
+ const { user: attacker } = await seedUser();
+
+ await seedPendingDocument(sender, senderTeam.id, [victim], {
+ createDocumentOptions: { title: 'Confidential Merger Agreement' },
+ });
+
+ await seedCompletedDocument(sender, senderTeam.id, [victim], {
+ createDocumentOptions: { title: 'Confidential Severance Package' },
+ });
+
+ // Attacker lands directly on a crafted URL with the exact title.
+ await apiSignin({
+ page,
+ email: attacker.email,
+ redirectPath: '/inbox?query=Confidential%20Merger%20Agreement',
+ });
+
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(inboxRow(page, 'Confidential Merger Agreement')).not.toBeVisible();
+
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`);
+
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(inboxRow(page, 'Confidential Severance Package')).not.toBeVisible();
+
+ // Wildcards must not widen the search to everything.
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?query=%25`);
+
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(page.getByText('Confidential', { exact: false })).not.toBeVisible();
+ });
+
+ test('should ignore tampered status values', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ // The recipient is attached to a draft that has not been sent yet. It must
+ // never be reachable via the URL, regardless of the status requested.
+ await seedDraftDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Unsent Draft Document' },
+ });
+
+ await seedCancelledDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Cancelled Document' },
+ });
+
+ await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Legit Pending Document' },
+ });
+
+ // Draft, virtual and derived statuses are ignored, showing the unfiltered
+ // non-draft view. Kept to a handful of full page loads per test since each
+ // one is a fresh navigation.
+ const expectUnfilteredView = async () => {
+ await expect(page.getByTestId('documents-table-status-filter')).toHaveText('Status');
+ await expect(inboxRow(page, 'Legit Pending Document')).toBeVisible();
+ await expect(inboxRow(page, 'Cancelled Document')).toBeVisible();
+ await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible();
+ };
+
+ await apiSignin({ page, email: recipient.email, redirectPath: '/inbox?status=DRAFT' });
+ await expectUnfilteredView();
+
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?status=ALL`);
+ await expectUnfilteredView();
+
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?status=EXPIRED`);
+ await expectUnfilteredView();
+
+ // Searching by the exact title of the draft must not surface it either.
+ await searchInbox(page, 'Unsent Draft');
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+
+ // Supported non-pending statuses are scoped to exactly that status.
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?status=CANCELLED`);
+
+ await expect(page.getByTestId('documents-table-status-filter')).toContainText(
+ INBOX_STATUS_LABELS[DocumentStatus.CANCELLED],
+ );
+ await expect(inboxRow(page, 'Cancelled Document')).toBeVisible();
+ await expect(inboxRow(page, 'Legit Pending Document')).not.toBeVisible();
+ await expect(inboxRow(page, 'Unsent Draft Document')).not.toBeVisible();
+ });
+
+ test('should not show deleted or CC documents even when searched by exact title', async ({ page }) => {
+ const { user: sender, team: senderTeam } = await seedUser();
+ const { user: recipient } = await seedUser();
+
+ const deletedDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'Deleted Pending Document' },
+ });
+
+ await prisma.envelope.update({
+ where: { id: deletedDocument.id },
+ data: { deletedAt: new Date() },
+ });
+
+ const ccDocument = await seedPendingDocument(sender, senderTeam.id, [recipient], {
+ createDocumentOptions: { title: 'CC Only Pending Document' },
+ });
+
+ await prisma.recipient.updateMany({
+ where: { envelopeId: ccDocument.id, email: recipient.email },
+ data: { role: RecipientRole.CC },
+ });
+
+ await apiSignin({ page, email: recipient.email, redirectPath: '/inbox' });
+
+ await expect(page.getByText(DEFAULT_EMPTY_STATE)).toBeVisible();
+
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Deleted%20Pending`);
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(inboxRow(page, 'Deleted Pending Document')).not.toBeVisible();
+
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?query=CC%20Only`);
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(inboxRow(page, 'CC Only Pending Document')).not.toBeVisible();
+ });
+
+ test('should not show team documents to team members who are not recipients', async ({ page }) => {
+ const { team, owner } = await seedTeam();
+ const { user: outsideRecipient } = await seedUser();
+
+ const teamAdmin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
+
+ await seedPendingDocument(owner, team.id, [outsideRecipient], {
+ createDocumentOptions: { title: 'Team Payroll Summary' },
+ });
+
+ await seedCompletedDocument(owner, team.id, [outsideRecipient], {
+ createDocumentOptions: { title: 'Team Board Minutes' },
+ });
+
+ // A team admin sees these on the team documents page, but the personal
+ // inbox is strictly recipient scoped.
+ await apiSignin({ page, email: teamAdmin.email, redirectPath: '/inbox?query=Team' });
+
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(inboxRow(page, 'Team Payroll Summary')).not.toBeVisible();
+
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`);
+
+ await expect(page.getByText(SEARCH_EMPTY_STATE)).toBeVisible();
+ await expect(inboxRow(page, 'Team Board Minutes')).not.toBeVisible();
+
+ // Positive control: the actual recipient can find both.
+ await page.context().clearCookies();
+ await apiSignin({ page, email: outsideRecipient.email, redirectPath: '/inbox?query=Team' });
+
+ await expect(inboxRow(page, 'Team Payroll Summary')).toBeVisible();
+
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Team&status=COMPLETED`);
+
+ await expect(inboxRow(page, 'Team Board Minutes')).toBeVisible();
+ });
+
+ test('should redirect unauthenticated users away from the inbox', async ({ page }) => {
+ await page.goto(`${WEBAPP_BASE_URL}/inbox?query=Confidential&status=COMPLETED`);
+
+ await expect(page).toHaveURL(/\/signin/);
+ });
+});
diff --git a/packages/trpc/server/document-router/find-inbox.ts b/packages/trpc/server/document-router/find-inbox.ts
index 13e360a1d..72d7a39a3 100644
--- a/packages/trpc/server/document-router/find-inbox.ts
+++ b/packages/trpc/server/document-router/find-inbox.ts
@@ -5,13 +5,13 @@ import type { Envelope, Prisma } from '@prisma/client';
import { DocumentStatus, EnvelopeType, RecipientRole } from '@prisma/client';
import { authenticatedProcedure } from '../trpc';
-import { ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types';
+import { type TInboxStatus, ZFindInboxRequestSchema, ZFindInboxResponseSchema } from './find-inbox.types';
export const findInboxRoute = authenticatedProcedure
.input(ZFindInboxRequestSchema)
.output(ZFindInboxResponseSchema)
.query(async ({ input, ctx }) => {
- const { page, perPage } = input;
+ const { page, perPage, query, status } = input;
const userId = ctx.user.id;
@@ -19,6 +19,8 @@ export const findInboxRoute = authenticatedProcedure
userId,
page,
perPage,
+ query,
+ status,
});
return {
@@ -31,13 +33,22 @@ export type FindInboxOptions = {
userId: number;
page?: number;
perPage?: number;
+ /**
+ * Case insensitive search against the document title.
+ */
+ query?: string;
+
+ /**
+ * Restrict results to a single status. When omitted, every non-draft status is returned.
+ */
+ status?: TInboxStatus;
orderBy?: {
column: keyof Omit;
direction: 'asc' | 'desc';
};
};
-export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: FindInboxOptions) => {
+export const findInbox = async ({ userId, page = 1, perPage = 10, query = '', status, orderBy }: FindInboxOptions) => {
const user = await prisma.user.findFirstOrThrow({
where: {
id: userId,
@@ -50,10 +61,11 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
const orderByColumn = orderBy?.column ?? 'createdAt';
const orderByDirection = orderBy?.direction ?? 'desc';
+ const searchQuery = query.trim();
const whereClause: Prisma.EnvelopeWhereInput = {
type: EnvelopeType.DOCUMENT,
- status: {
+ status: status ?? {
not: DocumentStatus.DRAFT,
},
deletedAt: null,
@@ -67,6 +79,13 @@ export const findInbox = async ({ userId, page = 1, perPage = 10, orderBy }: Fin
},
};
+ if (searchQuery.length > 0) {
+ whereClause.title = {
+ contains: searchQuery,
+ mode: 'insensitive',
+ };
+ }
+
const [data, count] = await Promise.all([
prisma.envelope.findMany({
where: whereClause,
diff --git a/packages/trpc/server/document-router/find-inbox.types.ts b/packages/trpc/server/document-router/find-inbox.types.ts
index 76e1855a9..ddbcebb55 100644
--- a/packages/trpc/server/document-router/find-inbox.types.ts
+++ b/packages/trpc/server/document-router/find-inbox.types.ts
@@ -2,12 +2,33 @@
import { ZDocumentManySchema } from '@documenso/lib/types/document';
import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params';
-import type { z } from 'zod';
+import { DocumentStatus } from '@prisma/client';
+import { z } from 'zod';
-export const ZFindInboxRequestSchema = ZFindSearchParamsSchema;
+/**
+ * The statuses that can be filtered by in the inbox.
+ *
+ * Every document status except DRAFT, since drafts have not been sent to
+ * recipients yet and must never be visible in the inbox.
+ */
+export const INBOX_STATUSES = [
+ DocumentStatus.PENDING,
+ DocumentStatus.COMPLETED,
+ DocumentStatus.REJECTED,
+ DocumentStatus.CANCELLED,
+] as const;
+
+export const ZInboxStatusSchema = z.enum(INBOX_STATUSES);
+
+export type TInboxStatus = z.infer;
+
+export const ZFindInboxRequestSchema = ZFindSearchParamsSchema.extend({
+ status: ZInboxStatusSchema.describe('Filter the inbox by document status.').optional(),
+});
export const ZFindInboxResponseSchema = ZFindResultResponse.extend({
data: ZDocumentManySchema.array(),
});
+export type TFindInboxRequest = z.infer;
export type TFindInboxResponse = z.infer;