mirror of
https://github.com/documenso/documenso.git
synced 2026-10-03 02:04:40 +10:00
feat: replace template view tabs with a filter pill (#3148)
This commit is contained in:
@@ -44,7 +44,10 @@ export const EnvelopesBulkDeleteDialog = ({
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (result.failedIds.length > 0) {
|
||||
|
||||
@@ -96,7 +96,10 @@ export const EnvelopesBulkMoveDialog = ({
|
||||
if (isDocument) {
|
||||
await trpcUtils.document.findDocumentsInternal.invalidate();
|
||||
} else {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
}
|
||||
|
||||
await onSuccess?.(data.folderId);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounce
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
@@ -10,16 +10,26 @@ import { documentsSearchParams } from '~/utils/documents-search-params';
|
||||
export const DocumentSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [query, setQuery] = useQueryState('query', documentsSearchParams.query);
|
||||
const [{ query }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
query: documentsSearchParams.query,
|
||||
page: documentsSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setQuery(debouncedSearchTerm || null);
|
||||
// Reset pagination so a new search never lands on an empty page.
|
||||
void setSearchParams({
|
||||
query: debouncedSearchTerm || null,
|
||||
page: null,
|
||||
});
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setQuery]);
|
||||
}, [debouncedSearchTerm, query, setSearchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
export const TemplateSearch = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [{ query }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
query: templatesSearchParams.query,
|
||||
page: templatesSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query ?? '');
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm !== (query ?? '')) {
|
||||
void setSearchParams({
|
||||
query: debouncedSearchTerm || null,
|
||||
page: null,
|
||||
});
|
||||
}
|
||||
}, [debouncedSearchTerm, query, setSearchParams]);
|
||||
|
||||
return (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={_(msg`Search templates...`)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
data-testid="templates-search-input"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -170,7 +170,10 @@ export const TemplatesTableActionDropdown = ({
|
||||
onOpenChange={setRenameDialogOpen}
|
||||
envelopeType="template"
|
||||
onSuccess={async () => {
|
||||
await trpcUtils.template.findTemplates.invalidate();
|
||||
await Promise.all([
|
||||
trpcUtils.template.findTemplates.invalidate(),
|
||||
trpcUtils.template.findTemplatesInternal.invalidate(),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { UserIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
type TemplatesTableOwnerFilterProps = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const TemplatesTableOwnerFilter = ({ teamId }: TemplatesTableOwnerFilterProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [{ ownerIds }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
ownerIds: templatesSearchParams.ownerIds,
|
||||
page: templatesSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const selectedOwnerIds = (ownerIds ?? []).map((ownerId) => ownerId.toString());
|
||||
|
||||
const { data, isLoading } = trpc.team.member.getMany.useQuery({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const options = (data ?? []).map((member) => ({
|
||||
label: member.name ?? member.email,
|
||||
value: member.userId.toString(),
|
||||
}));
|
||||
|
||||
const onChange = (newOwnerIds: string[]) => {
|
||||
void setSearchParams({
|
||||
ownerIds: newOwnerIds.length > 0 ? newOwnerIds.map(Number) : null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
multiple
|
||||
icon={UserIcon}
|
||||
label={<Trans>Owner</Trans>}
|
||||
value={selectedOwnerIds}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
enableSearch
|
||||
searchPlaceholder={_(msg`Search members...`)}
|
||||
loading={!isMounted || isLoading}
|
||||
testId="templates-table-owner-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Building2Icon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
|
||||
import { FilterPill } from '~/components/general/filter-pill';
|
||||
import { TEMPLATES_VIEW_VALUES, templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ value: 'team', label: <Trans>Team</Trans> },
|
||||
{ value: 'organisation', label: <Trans>Organisation</Trans> },
|
||||
];
|
||||
|
||||
export const TemplatesTableViewFilter = () => {
|
||||
const [{ view }, setSearchParams] = useQueryStates(
|
||||
{
|
||||
view: templatesSearchParams.view,
|
||||
ownerIds: templatesSearchParams.ownerIds,
|
||||
page: templatesSearchParams.page,
|
||||
},
|
||||
{ history: 'push' },
|
||||
);
|
||||
|
||||
const onChange = (newView: string | null) => {
|
||||
// The owner filter only applies to the team view, so drop it on any view change.
|
||||
void setSearchParams({
|
||||
view: TEMPLATES_VIEW_VALUES.find((value) => value === newView) ?? null,
|
||||
ownerIds: null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FilterPill
|
||||
icon={Building2Icon}
|
||||
label={<Trans>View</Trans>}
|
||||
value={view}
|
||||
onChange={onChange}
|
||||
options={VIEW_OPTIONS}
|
||||
testId="templates-table-view-filter"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,26 +5,28 @@ import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { RowSelectionState } from '@documenso/ui/primitives/data-table';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, OrganisationType } from '@prisma/client';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { parseAsStringLiteral, useQueryState } from 'nuqs';
|
||||
import { Bird, XIcon } from 'lucide-react';
|
||||
import { useQueryStates } from 'nuqs';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { EnvelopesBulkDeleteDialog } from '~/components/dialogs/envelopes-bulk-delete-dialog';
|
||||
import { EnvelopesBulkMoveDialog } from '~/components/dialogs/envelopes-bulk-move-dialog';
|
||||
import { EnvelopeDropZoneWrapper } from '~/components/general/envelope/envelope-drop-zone-wrapper';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { TemplateSearch } from '~/components/general/template/template-search';
|
||||
import { EnvelopesTableBulkActionBar } from '~/components/tables/envelopes-table-bulk-action-bar';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { TemplatesTableOwnerFilter } from '~/components/tables/templates-table-owner-filter';
|
||||
import { TemplatesTableViewFilter } from '~/components/tables/templates-table-view-filter';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
const TEMPLATE_VIEWS = ['team', 'organisation'] as const;
|
||||
import { templatesSearchParams } from '~/utils/templates-search-params';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags(msg`Templates`);
|
||||
@@ -39,15 +41,26 @@ export default function TemplatesPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { folderId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [findTemplateSearchParams, setFindTemplateSearchParams] = useQueryStates(templatesSearchParams, {
|
||||
history: 'push',
|
||||
});
|
||||
const page = findTemplateSearchParams.page || undefined;
|
||||
const perPage = findTemplateSearchParams.perPage || undefined;
|
||||
const query = findTemplateSearchParams.query || undefined;
|
||||
const ownerIds = findTemplateSearchParams.ownerIds ?? undefined;
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
const isOrgView = findTemplateSearchParams.view === 'organisation';
|
||||
const showOrgFilter = organisation.type !== OrganisationType.PERSONAL;
|
||||
|
||||
const [view, setView] = useQueryState('view', parseAsStringLiteral(TEMPLATE_VIEWS).withDefault('team'));
|
||||
const hasActiveFilters = Boolean(ownerIds?.length);
|
||||
const isSearchingOrFiltering = hasActiveFilters || Boolean(query);
|
||||
|
||||
const isOrgView = view === 'organisation';
|
||||
const showOrgTab = organisation.type !== OrganisationType.PERSONAL;
|
||||
const onResetFilters = () => {
|
||||
void setFindTemplateSearchParams({
|
||||
ownerIds: null,
|
||||
page: null,
|
||||
});
|
||||
};
|
||||
|
||||
// Scoped by team so selections made in one team never leak into another.
|
||||
const [rowSelection, setRowSelection] = useSessionStorage<RowSelectionState>(
|
||||
@@ -64,11 +77,13 @@ export default function TemplatesPage() {
|
||||
const documentRootPath = formatDocumentsPath(team.url);
|
||||
const templateRootPath = formatTemplatesPath(team.url);
|
||||
|
||||
const teamTemplatesQuery = trpc.template.findTemplates.useQuery(
|
||||
const teamTemplatesQuery = trpc.template.findTemplatesInternal.useQuery(
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
folderId,
|
||||
query,
|
||||
ownerIds,
|
||||
},
|
||||
{
|
||||
enabled: !isOrgView,
|
||||
@@ -79,6 +94,7 @@ export default function TemplatesPage() {
|
||||
{
|
||||
page,
|
||||
perPage,
|
||||
query,
|
||||
},
|
||||
{
|
||||
enabled: isOrgView,
|
||||
@@ -87,14 +103,6 @@ export default function TemplatesPage() {
|
||||
|
||||
const activeQuery = isOrgView ? orgTemplatesQuery : teamTemplatesQuery;
|
||||
|
||||
const handleViewChange = (newView: string) => {
|
||||
if (newView !== 'team' && newView !== 'organisation') {
|
||||
return;
|
||||
}
|
||||
|
||||
void setView(newView === 'team' ? null : newView);
|
||||
};
|
||||
|
||||
return (
|
||||
<EnvelopeDropZoneWrapper type={EnvelopeType.TEMPLATE}>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
@@ -112,31 +120,25 @@ export default function TemplatesPage() {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{showOrgTab && (
|
||||
<div className="mt-6">
|
||||
<Tabs value={view} onValueChange={handleViewChange} data-testid="template-view-tabs">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
value="team"
|
||||
data-testid="template-tab-team"
|
||||
>
|
||||
<Trans>Team</Trans>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
className="min-w-[60px] hover:text-foreground"
|
||||
value="organisation"
|
||||
data-testid="template-tab-organisation"
|
||||
>
|
||||
<Trans>Organisation</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="mt-6 flex flex-wrap items-center gap-x-2 gap-y-4">
|
||||
<div className="w-56">
|
||||
<TemplateSearch />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showOrgFilter && <TemplatesTableViewFilter />}
|
||||
|
||||
{!isOrgView && <TemplatesTableOwnerFilter teamId={team.id} />}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" className="px-2 text-muted-foreground lg:px-3" onClick={onResetFilters}>
|
||||
<Trans>Reset</Trans>
|
||||
<XIcon className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
{activeQuery.data && activeQuery.data.count === 0 ? (
|
||||
{activeQuery.data && activeQuery.data.count === 0 && !isSearchingOrFiltering ? (
|
||||
<div className="flex h-96 flex-col items-center justify-center gap-y-4 text-muted-foreground/60">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs';
|
||||
|
||||
export const TEMPLATES_VIEW_VALUES = ['team', 'organisation'] as const;
|
||||
|
||||
/**
|
||||
* Shared nuqs parsers for the templates page URL state.
|
||||
*
|
||||
* Used by the templates page and its filter components so every consumer
|
||||
* parses and serialises the params identically.
|
||||
*/
|
||||
export const templatesSearchParams = {
|
||||
view: parseAsStringLiteral(TEMPLATES_VIEW_VALUES),
|
||||
ownerIds: parseAsArrayOf(parseAsInteger),
|
||||
page: parseAsInteger,
|
||||
perPage: parseAsInteger,
|
||||
query: parseAsString,
|
||||
};
|
||||
@@ -167,6 +167,29 @@ test.describe('Find Documents UI - Personal Context', () => {
|
||||
await expect(page.getByRole('link', { name: 'Annual Budget Plan', exact: true })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should reset pagination when the search query changes', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
|
||||
await seedDraftDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { title: 'Quarterly Report 2024' },
|
||||
});
|
||||
await seedDraftDocument(owner, team.id, [], {
|
||||
createDocumentOptions: { title: 'Annual Budget Plan' },
|
||||
});
|
||||
|
||||
// Start on a page that would be empty once the search narrows the results.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/documents?page=2&perPage=1`,
|
||||
});
|
||||
|
||||
await page.getByPlaceholder('Search documents...').fill('Quarterly');
|
||||
await page.waitForURL((url) => url.searchParams.get('query') === 'Quarterly' && !url.searchParams.has('page'));
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show deleted documents', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
|
||||
|
||||
@@ -40,6 +40,88 @@ test('[TEMPLATES]: view templates', async ({ page }) => {
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: search templates by title', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
await seedTemplate({
|
||||
title: 'Quarterly Report Template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await seedTemplate({
|
||||
title: 'Annual Budget Template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
|
||||
await page.getByPlaceholder('Search templates...').fill('Quarterly');
|
||||
await page.waitForURL(/query=Quarterly/);
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
|
||||
await expect(page.getByRole('link', { name: 'Quarterly Report Template' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Annual Budget Template' })).not.toBeVisible();
|
||||
|
||||
// Clearing the search should restore the full list and drop the URL param.
|
||||
await page.getByPlaceholder('Search templates...').fill('');
|
||||
await page.waitForURL((url) => !url.searchParams.has('query'));
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: filter templates by owner', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const teamMemberUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
name: 'Filter Member',
|
||||
role: TeamMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await seedTemplate({
|
||||
title: 'Owner Template',
|
||||
userId: owner.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await seedTemplate({
|
||||
title: 'Member Template',
|
||||
userId: teamMemberUser.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
|
||||
await page.getByTestId('templates-table-owner-filter').click();
|
||||
await page.getByRole('option', { name: 'Filter Member' }).click();
|
||||
await page.waitForURL(/ownerIds=/);
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 1 result');
|
||||
await expect(page.getByRole('link', { name: 'Member Template' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Owner Template' })).not.toBeVisible();
|
||||
|
||||
// Reset should clear the owner filter.
|
||||
await page.getByRole('button', { name: 'Reset' }).click();
|
||||
await page.waitForURL((url) => !url.searchParams.has('ownerIds'));
|
||||
|
||||
await expect(page.getByTestId('data-table-count')).toContainText('Showing 2 results');
|
||||
});
|
||||
|
||||
test('[TEMPLATES]: delete template', async ({ page }) => {
|
||||
const { team, owner, organisation } = await seedTeam({
|
||||
createTeamMembers: 1,
|
||||
|
||||
@@ -98,10 +98,10 @@ const trpcMutation = async (page: Page, procedure: string, input: Record<string,
|
||||
return { res, json: res.ok() ? await res.json() : null };
|
||||
};
|
||||
|
||||
// ─── UI: Tab Visibility ──────────────────────────────────────────────────────
|
||||
// ─── UI: View Filter Visibility ──────────────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - UI Tabs', () => {
|
||||
test('should show Team/Organisation tabs for non-personal orgs', async ({ page }) => {
|
||||
test.describe('Organisation Templates - UI View Filter', () => {
|
||||
test('should show the view filter for non-personal orgs', async ({ page }) => {
|
||||
const { ownerA, teamA } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
@@ -110,11 +110,10 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
||||
redirectPath: `/t/${teamA.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).toBeVisible();
|
||||
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show tabs for personal organisations', async ({ page }) => {
|
||||
test('should not show the view filter for personal organisations', async ({ page }) => {
|
||||
const { user, team } = await seedUser({ isPersonalOrganisation: true });
|
||||
|
||||
await apiSignin({
|
||||
@@ -123,15 +122,14 @@ test.describe('Organisation Templates - UI Tabs', () => {
|
||||
redirectPath: `/t/${team.url}/templates`,
|
||||
});
|
||||
|
||||
await expect(page.getByTestId('template-tab-team')).not.toBeVisible();
|
||||
await expect(page.getByTestId('template-tab-organisation')).not.toBeVisible();
|
||||
await expect(page.getByTestId('templates-table-view-filter')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── UI: Listing Organisation Templates ──────────────────────────────────────
|
||||
|
||||
test.describe('Organisation Templates - Listing', () => {
|
||||
test('should list org templates from other teams under the Organisation tab', async ({ page }) => {
|
||||
test('should list org templates from other teams under the organisation view', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
@@ -140,17 +138,30 @@ test.describe('Organisation Templates - Listing', () => {
|
||||
redirectPath: `/t/${teamB.url}/templates`,
|
||||
});
|
||||
|
||||
// Team tab should show 0 (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('template-tab-team')).toBeVisible();
|
||||
// Team view is active by default (memberB has no templates on teamB).
|
||||
await expect(page.getByTestId('templates-table-view-filter')).toBeVisible();
|
||||
|
||||
// Switch to Organisation tab.
|
||||
await page.getByTestId('template-tab-organisation').click();
|
||||
// Switch to the organisation view.
|
||||
await page.getByTestId('templates-table-view-filter').click();
|
||||
await page.getByRole('option', { name: 'Organization' }).click();
|
||||
|
||||
// Should see the org template from teamA.
|
||||
await expect(page.getByText(orgTemplate.title)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show private templates from other teams under Organisation tab', async ({ page }) => {
|
||||
test('should use default pagination when URL values are zero', async ({ page }) => {
|
||||
const { memberB, teamB, orgTemplate } = await seedOrgTemplateScenario();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberB.email,
|
||||
redirectPath: `/t/${teamB.url}/templates?view=organisation&page=0&perPage=0`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(orgTemplate.title)).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not show private templates from other teams under the organisation view', async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedOrgTemplateScenario();
|
||||
|
||||
// Create a private template on teamA — should NOT appear in org tab.
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createTeam } from '@documenso/lib/server-only/team/create-team';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { APIResponse, Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentVisibility, FolderType, TeamMemberRole, TemplateType } from '@prisma/client';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
|
||||
const nanoid = customAlphabet('1234567890abcdef', 10);
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
type FindTemplatesResult = {
|
||||
data: Array<{ title: string; userId: number; teamId: number }>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type TrpcResponse = {
|
||||
response: APIResponse;
|
||||
result: FindTemplatesResult | null;
|
||||
};
|
||||
|
||||
const trpcQuery = async (
|
||||
page: Page,
|
||||
procedure: string,
|
||||
input: Record<string, unknown>,
|
||||
teamId?: number,
|
||||
): Promise<TrpcResponse> => {
|
||||
const inputParam = encodeURIComponent(JSON.stringify({ json: { page: 1, perPage: 50, ...input } }));
|
||||
const url = `${WEBAPP_BASE_URL}/api/trpc/${procedure}?input=${inputParam}`;
|
||||
|
||||
const headers: Record<string, string> = teamId ? { 'x-team-id': teamId.toString() } : {};
|
||||
|
||||
const response = await page.context().request.get(url, { headers });
|
||||
|
||||
const json = response.ok() ? await response.json() : null;
|
||||
const result: FindTemplatesResult | null = json ? json.result.data.json : null;
|
||||
|
||||
return { response, result };
|
||||
};
|
||||
|
||||
const FIND_TEMPLATE_PROCEDURES = ['template.findTemplates', 'template.findTemplatesInternal'] as const;
|
||||
|
||||
const titlesOf = (res: TrpcResponse) => (res.result?.data ?? []).map((row) => row.title);
|
||||
|
||||
const expectRejected = (res: TrpcResponse, status: number) => {
|
||||
expect(res.response.status()).toBe(status);
|
||||
expect(res.result).toBeNull();
|
||||
};
|
||||
|
||||
// Check both count and data so one cannot be wrong while the other looks fine.
|
||||
const expectNoResults = (res: TrpcResponse) => {
|
||||
expect(res.response.ok()).toBeTruthy();
|
||||
expect(res.result?.count).toBe(0);
|
||||
expect(res.result?.data).toEqual([]);
|
||||
};
|
||||
|
||||
const expectExactTitles = (res: TrpcResponse, titles: string[], teamId: number) => {
|
||||
expect(res.response.ok()).toBeTruthy();
|
||||
expect(res.result?.count).toBe(titles.length);
|
||||
expect(titlesOf(res).sort()).toEqual([...titles].sort());
|
||||
expect(res.result?.data.every((row) => row.teamId === teamId)).toBe(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Org A has two teams. teamA is the default team, so every org member is in it.
|
||||
* teamB was created with inheritMembers: false, so only people added directly are in it.
|
||||
* - ownerA: org owner and teamA admin. Owns all the templates below.
|
||||
* - memberA, managerA: member and manager of teamA. Not in teamB.
|
||||
* - memberB: member of teamB, and also a member of teamA because teamA inherits.
|
||||
* - teamA has four templates with the same suffix in the title, one per visibility:
|
||||
* everyone, manager, admin (with a unique externalId and recipient email), and org.
|
||||
*
|
||||
* Org B is a separate org owned by "outsider". No overlap with Org A.
|
||||
*
|
||||
* So: memberB with a teamB header checks that teamA rows never come back.
|
||||
* memberA with a teamB header checks that non-members are rejected.
|
||||
*/
|
||||
const seedScenario = async () => {
|
||||
const { user: ownerA, organisation, team: teamA } = await seedUser();
|
||||
|
||||
const teamBUrl = `team-b-${nanoid()}`;
|
||||
|
||||
await createTeam({
|
||||
userId: ownerA.id,
|
||||
teamName: `Team B ${teamBUrl}`,
|
||||
teamUrl: teamBUrl,
|
||||
organisationId: organisation.id,
|
||||
inheritMembers: false,
|
||||
});
|
||||
|
||||
const teamB = await prisma.team.findFirstOrThrow({ where: { url: teamBUrl } });
|
||||
|
||||
const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER });
|
||||
const managerA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MANAGER });
|
||||
const memberB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
|
||||
const suffix = nanoid();
|
||||
const hiddenRecipientEmail = `hidden-recipient-${suffix}@example.com`;
|
||||
|
||||
const everyoneTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Everyone Template ${suffix}`,
|
||||
visibility: DocumentVisibility.EVERYONE,
|
||||
},
|
||||
});
|
||||
|
||||
const managerTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Manager Template ${suffix}`,
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
},
|
||||
});
|
||||
|
||||
const adminTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Admin Only Template ${suffix}`,
|
||||
externalId: `admin-external-${suffix}`,
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
recipients: {
|
||||
create: {
|
||||
email: hiddenRecipientEmail,
|
||||
name: `Hidden Recipient ${suffix}`,
|
||||
token: nanoid(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const orgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Org Template ${suffix}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: DocumentVisibility.EVERYONE,
|
||||
},
|
||||
});
|
||||
|
||||
const allTitles = [everyoneTemplate.title, orgTemplate.title, managerTemplate.title, adminTemplate.title];
|
||||
|
||||
// Which templates each role on teamA should see.
|
||||
const visibilityMatrix = [
|
||||
{ caller: memberA, visible: [everyoneTemplate.title, orgTemplate.title] },
|
||||
{ caller: managerA, visible: [everyoneTemplate.title, orgTemplate.title, managerTemplate.title] },
|
||||
{ caller: ownerA, visible: allTitles },
|
||||
];
|
||||
|
||||
return {
|
||||
ownerA,
|
||||
memberA,
|
||||
managerA,
|
||||
memberB,
|
||||
teamA,
|
||||
teamB,
|
||||
outsider,
|
||||
outsiderTeam,
|
||||
suffix,
|
||||
everyoneTemplate,
|
||||
managerTemplate,
|
||||
adminTemplate,
|
||||
orgTemplate,
|
||||
hiddenRecipientEmail,
|
||||
allTitles,
|
||||
visibilityMatrix,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── Not logged in, or using a team header for a team you are not in ─────────
|
||||
|
||||
test.describe('Find Templates API - Adversarial: Auth and Team Header', () => {
|
||||
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
|
||||
test(`${procedure}: should reject unauthenticated requests`, async ({ page }) => {
|
||||
const { teamA } = await seedScenario();
|
||||
|
||||
const res = await trpcQuery(page, procedure, {}, teamA.id);
|
||||
|
||||
expectRejected(res, 401);
|
||||
});
|
||||
|
||||
test(`${procedure}: should reject a team header for a team the user is not in`, async ({ page }) => {
|
||||
const { memberA, teamA, teamB, outsider } = await seedScenario();
|
||||
|
||||
const adminA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN });
|
||||
|
||||
const cases = [
|
||||
{ name: 'org member, not in team', caller: memberA, teamId: teamB.id },
|
||||
{ name: 'admin of another team', caller: adminA, teamId: teamB.id },
|
||||
{ name: 'other organisation', caller: outsider, teamId: teamA.id },
|
||||
{ name: 'no team header', caller: memberA, teamId: undefined },
|
||||
];
|
||||
|
||||
for (const { caller, teamId } of cases) {
|
||||
await apiSignin({ page, email: caller.email });
|
||||
|
||||
const res = await trpcQuery(page, procedure, {}, teamId);
|
||||
|
||||
expectRejected(res, 404);
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('findOrganisationTemplates: should reject a team header for a team in another org', async ({ page }) => {
|
||||
const { outsider, teamA } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const res = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamA.id);
|
||||
|
||||
expectRejected(res, 404);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Search must not find templates you cannot see ──────────────────────────
|
||||
|
||||
test.describe('Find Templates API - Adversarial: Search', () => {
|
||||
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
|
||||
test(`${procedure}: search must not find templates hidden by role`, async ({ page }) => {
|
||||
const { memberA, teamA, adminTemplate, hiddenRecipientEmail } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: memberA.email });
|
||||
|
||||
for (const query of [adminTemplate.title, adminTemplate.externalId, hiddenRecipientEmail]) {
|
||||
const res = await trpcQuery(page, procedure, { query }, teamA.id);
|
||||
|
||||
expectNoResults(res);
|
||||
}
|
||||
});
|
||||
|
||||
test(`${procedure}: search must not find templates from another team`, async ({ page }) => {
|
||||
const { memberB, teamB, everyoneTemplate } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, teamB.id);
|
||||
|
||||
expectNoResults(res);
|
||||
});
|
||||
|
||||
test(`${procedure}: search must not find templates from another org`, async ({ page }) => {
|
||||
const { outsider, outsiderTeam, everyoneTemplate } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const res = await trpcQuery(page, procedure, { query: everyoneTemplate.title }, outsiderTeam.id);
|
||||
|
||||
expectNoResults(res);
|
||||
});
|
||||
|
||||
test(`${procedure}: list and search show only what each role is allowed to see`, async ({ page }) => {
|
||||
const { teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
|
||||
|
||||
for (const { caller, visible } of visibilityMatrix) {
|
||||
await apiSignin({ page, email: caller.email });
|
||||
|
||||
const listRes = await trpcQuery(page, procedure, {}, teamA.id);
|
||||
|
||||
expectExactTitles(listRes, visible, teamA.id);
|
||||
|
||||
const searchRes = await trpcQuery(page, procedure, { query: suffix }, teamA.id);
|
||||
|
||||
expectExactTitles(searchRes, visible, teamA.id);
|
||||
|
||||
for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
|
||||
const res = await trpcQuery(page, procedure, { query: hidden }, teamA.id);
|
||||
|
||||
expectNoResults(res);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test(`${procedure}: wildcard search must not show more than allowed`, async ({ page }) => {
|
||||
const { memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: memberA.email });
|
||||
|
||||
// % and _ are not escaped, so these match everything you are allowed to see.
|
||||
for (const query of ['%', '_', '%%%']) {
|
||||
const res = await trpcQuery(page, procedure, { query }, teamA.id);
|
||||
|
||||
expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
|
||||
}
|
||||
|
||||
for (const query of ['%Admin Only%', '%Manager%']) {
|
||||
const res = await trpcQuery(page, procedure, { query }, teamA.id);
|
||||
|
||||
expectNoResults(res);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('findOrganisationTemplates: search must not find templates from another org', async ({ page }) => {
|
||||
const { outsider, outsiderTeam, orgTemplate } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const res = await trpcQuery(
|
||||
page,
|
||||
'template.findOrganisationTemplates',
|
||||
{ query: orgTemplate.title },
|
||||
outsiderTeam.id,
|
||||
);
|
||||
|
||||
expectNoResults(res);
|
||||
});
|
||||
|
||||
test('findOrganisationTemplates: search must not find team templates from another team', async ({ page }) => {
|
||||
const { memberB, teamB, everyoneTemplate, managerTemplate, adminTemplate } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
for (const { title } of [everyoneTemplate, managerTemplate, adminTemplate]) {
|
||||
const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: title }, teamB.id);
|
||||
|
||||
expectNoResults(res);
|
||||
}
|
||||
});
|
||||
|
||||
test('findOrganisationTemplates: shows only what the user role on the requesting team allows', async ({ page }) => {
|
||||
const { ownerA, teamA, teamB, memberB, orgTemplate } = await seedScenario();
|
||||
|
||||
const managerOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Manager Org Template ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
|
||||
},
|
||||
});
|
||||
|
||||
const adminOrgTemplate = await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Admin Org Template ${nanoid()}`,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
const managerB = await seedTeamMember({ teamId: teamB.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const allOrgTitles = [orgTemplate.title, managerOrgTemplate.title, adminOrgTemplate.title];
|
||||
|
||||
const matrix = [
|
||||
{ caller: memberB, visible: [orgTemplate.title] },
|
||||
{ caller: managerB, visible: [orgTemplate.title, managerOrgTemplate.title] },
|
||||
];
|
||||
|
||||
for (const { caller, visible } of matrix) {
|
||||
await apiSignin({ page, email: caller.email });
|
||||
|
||||
const listRes = await trpcQuery(page, 'template.findOrganisationTemplates', {}, teamB.id);
|
||||
|
||||
expectExactTitles(listRes, visible, teamA.id);
|
||||
|
||||
for (const hidden of allOrgTitles.filter((title) => !visible.includes(title))) {
|
||||
const res = await trpcQuery(page, 'template.findOrganisationTemplates', { query: hidden }, teamB.id);
|
||||
|
||||
expectNoResults(res);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Owner filter must not reach other teams or skip visibility checks ───────
|
||||
|
||||
test.describe('Find Templates API - Adversarial: Owner Filter', () => {
|
||||
const procedure = 'template.findTemplatesInternal';
|
||||
|
||||
test('owner filter must not find templates from another team', async ({ page }) => {
|
||||
const { ownerA, memberB, teamB } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, teamB.id);
|
||||
|
||||
expectNoResults(res);
|
||||
});
|
||||
|
||||
test('owner filter must not find templates from another org', async ({ page }) => {
|
||||
const { ownerA, outsider, outsiderTeam } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: outsider.email });
|
||||
|
||||
const res = await trpcQuery(page, procedure, { ownerIds: [ownerA.id] }, outsiderTeam.id);
|
||||
|
||||
expectNoResults(res);
|
||||
});
|
||||
|
||||
test('owning a template only makes it visible in its own team', async ({ page }) => {
|
||||
const { memberB, teamA, teamB } = await seedScenario();
|
||||
|
||||
// memberB is in both teams and owns an admin-only template in each.
|
||||
// They can only see these because they own them. That must not cross teams.
|
||||
const ownedOnA = await seedBlankTemplate(memberB, teamA.id, {
|
||||
createTemplateOptions: { title: `Owned On A ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const ownedOnB = await seedBlankTemplate(memberB, teamB.id, {
|
||||
createTemplateOptions: { title: `Owned On B ${nanoid()}`, visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const inputs = [
|
||||
{},
|
||||
{ ownerIds: [memberB.id] },
|
||||
{ query: 'Owned On' },
|
||||
{ ownerIds: [memberB.id], query: 'Owned On' },
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
// teamB has no other templates, so this is the only row.
|
||||
const fromB = await trpcQuery(page, procedure, input, teamB.id);
|
||||
|
||||
expectExactTitles(fromB, [ownedOnB.title], teamB.id);
|
||||
|
||||
const fromA = await trpcQuery(page, procedure, input, teamA.id);
|
||||
|
||||
expect(fromA.response.ok()).toBeTruthy();
|
||||
expect(titlesOf(fromA)).toContain(ownedOnA.title);
|
||||
expect(titlesOf(fromA)).not.toContain(ownedOnB.title);
|
||||
}
|
||||
});
|
||||
|
||||
test('owner filter shows only what each role is allowed to see', async ({ page }) => {
|
||||
const { ownerA, teamA, suffix, allTitles, visibilityMatrix } = await seedScenario();
|
||||
|
||||
const ownerIds = [ownerA.id];
|
||||
|
||||
for (const { caller, visible } of visibilityMatrix) {
|
||||
await apiSignin({ page, email: caller.email });
|
||||
|
||||
const listRes = await trpcQuery(page, procedure, { ownerIds }, teamA.id);
|
||||
|
||||
expectExactTitles(listRes, visible, teamA.id);
|
||||
|
||||
const searchRes = await trpcQuery(page, procedure, { ownerIds, query: suffix }, teamA.id);
|
||||
|
||||
expectExactTitles(searchRes, visible, teamA.id);
|
||||
|
||||
for (const hidden of allTitles.filter((title) => !visible.includes(title))) {
|
||||
const res = await trpcQuery(page, procedure, { ownerIds, query: hidden }, teamA.id);
|
||||
|
||||
expectNoResults(res);
|
||||
}
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('owner filter with unknown user ids returns nothing', async ({ page }) => {
|
||||
const { ownerA, teamA } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: ownerA.email });
|
||||
|
||||
const res = await trpcQuery(page, procedure, { ownerIds: [-1, 999999999] }, teamA.id);
|
||||
|
||||
expectNoResults(res);
|
||||
});
|
||||
|
||||
test('owner filter is ignored by the public findTemplates route', async ({ page }) => {
|
||||
const { ownerA, memberA, teamA, everyoneTemplate, orgTemplate } = await seedScenario();
|
||||
|
||||
await apiSignin({ page, email: memberA.email });
|
||||
|
||||
// The public route does not accept ownerIds. It should be dropped, not error.
|
||||
const res = await trpcQuery(page, 'template.findTemplates', { ownerIds: [ownerA.id] }, teamA.id);
|
||||
|
||||
expectExactTitles(res, [everyoneTemplate.title, orgTemplate.title], teamA.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Folder filter must not reach other teams ────────────────────────────────
|
||||
|
||||
test.describe('Find Templates API - Adversarial: Folder Filter', () => {
|
||||
for (const procedure of FIND_TEMPLATE_PROCEDURES) {
|
||||
test(`${procedure}: folder filter must not find folders from another team`, async ({ page }) => {
|
||||
const { ownerA, teamA, memberB, teamB } = await seedScenario();
|
||||
|
||||
const folderA = await seedBlankFolder(ownerA, teamA.id, {
|
||||
createFolderOptions: { type: FolderType.TEMPLATE },
|
||||
});
|
||||
|
||||
await seedBlankTemplate(ownerA, teamA.id, {
|
||||
createTemplateOptions: {
|
||||
title: `Foldered Template ${nanoid()}`,
|
||||
visibility: DocumentVisibility.EVERYONE,
|
||||
folderId: folderA.id,
|
||||
},
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: memberB.email });
|
||||
|
||||
const res = await trpcQuery(page, procedure, { folderId: folderA.id }, teamB.id);
|
||||
|
||||
expectNoResults(res);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Builds the search clause for template listings.
|
||||
*
|
||||
* Matches the same fields as the documents search so both pages behave the
|
||||
* same way: title, external ID, and recipient name or email.
|
||||
*
|
||||
* Returns `null` when the query is empty so callers can skip the clause.
|
||||
*/
|
||||
export const buildTemplateSearchFilter = (query?: string): Prisma.EnvelopeWhereInput | null => {
|
||||
const searchQuery = query?.trim() ?? '';
|
||||
|
||||
if (searchQuery.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{ title: { contains: searchQuery, mode: 'insensitive' } },
|
||||
{ externalId: { contains: searchQuery, mode: 'insensitive' } },
|
||||
{
|
||||
recipients: {
|
||||
some: {
|
||||
OR: [
|
||||
{ name: { contains: searchQuery, mode: 'insensitive' } },
|
||||
{ email: { contains: searchQuery, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -5,12 +5,14 @@ import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { getTeamById } from '../team/get-team';
|
||||
import { buildTemplateSearchFilter } from './build-template-search-filter';
|
||||
|
||||
export type FindOrganisationTemplatesOptions = {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
query?: string;
|
||||
};
|
||||
|
||||
export const findOrganisationTemplates = async ({
|
||||
@@ -18,6 +20,7 @@ export const findOrganisationTemplates = async ({
|
||||
teamId,
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
query,
|
||||
}: FindOrganisationTemplatesOptions) => {
|
||||
const [team, { teamRole }] = await Promise.all([
|
||||
getTeamById({ teamId, userId }),
|
||||
@@ -30,6 +33,8 @@ export const findOrganisationTemplates = async ({
|
||||
}),
|
||||
]);
|
||||
|
||||
const searchFilter = buildTemplateSearchFilter(query);
|
||||
|
||||
const where: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
templateType: TemplateType.ORGANISATION,
|
||||
@@ -39,6 +44,7 @@ export const findOrganisationTemplates = async ({
|
||||
team: {
|
||||
organisationId: team.organisationId,
|
||||
},
|
||||
AND: searchFilter ? [searchFilter] : undefined,
|
||||
};
|
||||
|
||||
const templateInclude = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EnvelopeType, type Prisma } from '@prisma/client';
|
||||
import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams';
|
||||
import type { FindResultResponse } from '../../types/search-params';
|
||||
import { getMemberRoles } from '../team/get-member-roles';
|
||||
import { buildTemplateSearchFilter } from './build-template-search-filter';
|
||||
|
||||
export type FindTemplatesOptions = {
|
||||
userId: number;
|
||||
@@ -13,6 +14,8 @@ export type FindTemplatesOptions = {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
folderId?: string;
|
||||
query?: string;
|
||||
ownerIds?: number[];
|
||||
};
|
||||
|
||||
export const findTemplates = async ({
|
||||
@@ -22,6 +25,8 @@ export const findTemplates = async ({
|
||||
page = 1,
|
||||
perPage = 10,
|
||||
folderId,
|
||||
query,
|
||||
ownerIds,
|
||||
}: FindTemplatesOptions) => {
|
||||
const { teamRole } = await getMemberRoles({
|
||||
teamId,
|
||||
@@ -31,23 +36,35 @@ export const findTemplates = async ({
|
||||
},
|
||||
});
|
||||
|
||||
const filters: Prisma.EnvelopeWhereInput[] = [
|
||||
{ teamId },
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
|
||||
},
|
||||
},
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
folderId ? { folderId } : { folderId: null },
|
||||
];
|
||||
|
||||
if (ownerIds && ownerIds.length > 0) {
|
||||
filters.push({ userId: { in: ownerIds } });
|
||||
}
|
||||
|
||||
const searchFilter = buildTemplateSearchFilter(query);
|
||||
|
||||
if (searchFilter) {
|
||||
filters.push(searchFilter);
|
||||
}
|
||||
|
||||
const where: Prisma.EnvelopeWhereInput = {
|
||||
type: EnvelopeType.TEMPLATE,
|
||||
templateType: type,
|
||||
AND: [
|
||||
{ teamId },
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
visibility: {
|
||||
in: TEAM_DOCUMENT_VISIBILITY_MAP[teamRole],
|
||||
},
|
||||
},
|
||||
{ userId, teamId },
|
||||
],
|
||||
},
|
||||
folderId ? { folderId } : { folderId: null },
|
||||
],
|
||||
AND: filters,
|
||||
};
|
||||
|
||||
const templateInclude = {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Envelope, Recipient } from '@prisma/client';
|
||||
import type { DocumentMeta, Envelope, Field, Recipient } from '@prisma/client';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
|
||||
import type { TTemplateLite } from '../types/template';
|
||||
import { mapSecondaryIdToTemplateId } from './envelope';
|
||||
import { mapFieldToLegacyField } from './fields';
|
||||
import { mapRecipientToLegacyRecipient } from './recipients';
|
||||
|
||||
export const formatDirectTemplatePath = (token: string) => {
|
||||
return `${NEXT_PUBLIC_WEBAPP_URL()}/d/${token}`;
|
||||
@@ -67,3 +69,42 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite =>
|
||||
templateDocumentDataId: '',
|
||||
};
|
||||
};
|
||||
|
||||
type EnvelopeWithTemplateManyRelations = Envelope & {
|
||||
team: { id: number; url: string; name: string } | null;
|
||||
fields: Field[];
|
||||
recipients: Recipient[];
|
||||
documentMeta: DocumentMeta | null;
|
||||
directLink: { token: string; enabled: boolean } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps an envelope (with the relations loaded by the template find functions)
|
||||
* to the legacy "template many" response shape.
|
||||
*/
|
||||
export const mapEnvelopeToTemplateMany = (envelope: EnvelopeWithTemplateManyRelations) => {
|
||||
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
|
||||
|
||||
return {
|
||||
id: legacyTemplateId,
|
||||
envelopeId: envelope.id,
|
||||
type: envelope.templateType,
|
||||
visibility: envelope.visibility,
|
||||
externalId: envelope.externalId,
|
||||
title: envelope.title,
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
authOptions: envelope.authOptions,
|
||||
createdAt: envelope.createdAt,
|
||||
updatedAt: envelope.updatedAt,
|
||||
publicTitle: envelope.publicTitle,
|
||||
publicDescription: envelope.publicDescription,
|
||||
folderId: envelope.folderId,
|
||||
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
|
||||
team: envelope.team,
|
||||
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
|
||||
templateMeta: envelope.documentMeta,
|
||||
directLink: envelope.directLink,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { findTemplates } from '@documenso/lib/server-only/template/find-templates';
|
||||
import { mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import {
|
||||
ZFindTemplatesInternalRequestSchema,
|
||||
ZFindTemplatesInternalResponseSchema,
|
||||
} from './find-templates-internal.types';
|
||||
|
||||
export const findTemplatesInternalRoute = authenticatedProcedure
|
||||
.input(ZFindTemplatesInternalRequestSchema)
|
||||
.output(ZFindTemplatesInternalResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { user, teamId } = ctx;
|
||||
|
||||
const { query, type, folderId, page, perPage, ownerIds } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
folderId,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await findTemplates({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
query,
|
||||
type,
|
||||
folderId,
|
||||
page,
|
||||
perPage,
|
||||
ownerIds,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ZFindTemplatesRequestSchema, ZFindTemplatesResponseSchema } from './schema';
|
||||
|
||||
export const ZFindTemplatesInternalRequestSchema = ZFindTemplatesRequestSchema.extend({
|
||||
ownerIds: z.array(z.number()).optional(),
|
||||
});
|
||||
|
||||
export const ZFindTemplatesInternalResponseSchema = ZFindTemplatesResponseSchema;
|
||||
|
||||
export type TFindTemplatesInternalRequest = z.infer<typeof ZFindTemplatesInternalRequestSchema>;
|
||||
export type TFindTemplatesInternalResponse = z.infer<typeof ZFindTemplatesInternalResponseSchema>;
|
||||
@@ -27,15 +27,14 @@ import { fireAndForget } from '@documenso/lib/universal/fire-and-forget';
|
||||
import { putNormalizedPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { getPresignPostUrl } from '@documenso/lib/universal/upload/server-actions';
|
||||
import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
|
||||
import { mapFieldToLegacyField } from '@documenso/lib/utils/fields';
|
||||
import { mapRecipientToLegacyRecipient } from '@documenso/lib/utils/recipients';
|
||||
import { mapEnvelopeToTemplateLite } from '@documenso/lib/utils/templates';
|
||||
import { mapEnvelopeToTemplateLite, mapEnvelopeToTemplateMany } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { Envelope } from '@prisma/client';
|
||||
import { DocumentDataType, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { ZGenericSuccessResponse, ZSuccessResponseSchema } from '../schema';
|
||||
import { authenticatedProcedure, maybeAuthenticatedProcedure, router } from '../trpc';
|
||||
import { findTemplatesInternalRoute } from './find-templates-internal';
|
||||
import { getTemplatesByIdsRoute } from './get-templates-by-ids';
|
||||
import {
|
||||
ZBulkSendTemplateMutationSchema,
|
||||
@@ -102,35 +101,15 @@ export const templateRouter = router({
|
||||
// Remapping for backwards compatibility.
|
||||
return {
|
||||
...result,
|
||||
data: result.data.map((envelope) => {
|
||||
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
|
||||
|
||||
return {
|
||||
id: legacyTemplateId,
|
||||
envelopeId: envelope.id,
|
||||
type: envelope.templateType,
|
||||
visibility: envelope.visibility,
|
||||
externalId: envelope.externalId,
|
||||
title: envelope.title,
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
authOptions: envelope.authOptions,
|
||||
createdAt: envelope.createdAt,
|
||||
updatedAt: envelope.updatedAt,
|
||||
publicTitle: envelope.publicTitle,
|
||||
publicDescription: envelope.publicDescription,
|
||||
folderId: envelope.folderId,
|
||||
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
|
||||
team: envelope.team,
|
||||
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
|
||||
templateMeta: envelope.documentMeta,
|
||||
directLink: envelope.directLink,
|
||||
};
|
||||
}),
|
||||
data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
findTemplatesInternal: findTemplatesInternalRoute,
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
@@ -149,32 +128,7 @@ export const templateRouter = router({
|
||||
// Remapping for backwards compatibility.
|
||||
return {
|
||||
...result,
|
||||
data: result.data.map((envelope) => {
|
||||
const legacyTemplateId = mapSecondaryIdToTemplateId(envelope.secondaryId);
|
||||
|
||||
return {
|
||||
id: legacyTemplateId,
|
||||
envelopeId: envelope.id,
|
||||
type: envelope.templateType,
|
||||
visibility: envelope.visibility,
|
||||
externalId: envelope.externalId,
|
||||
title: envelope.title,
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
authOptions: envelope.authOptions,
|
||||
createdAt: envelope.createdAt,
|
||||
updatedAt: envelope.updatedAt,
|
||||
publicTitle: envelope.publicTitle,
|
||||
publicDescription: envelope.publicDescription,
|
||||
folderId: envelope.folderId,
|
||||
useLegacyFieldInsertion: envelope.useLegacyFieldInsertion,
|
||||
team: envelope.team,
|
||||
fields: envelope.fields.map((field) => mapFieldToLegacyField(field, envelope)),
|
||||
recipients: envelope.recipients.map((recipient) => mapRecipientToLegacyRecipient(recipient, envelope)),
|
||||
templateMeta: envelope.documentMeta,
|
||||
directLink: envelope.directLink,
|
||||
};
|
||||
}),
|
||||
data: result.data.map((envelope) => mapEnvelopeToTemplateMany(envelope)),
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user