fix(richtext-lexical, plugin-multi-tenant): text editor exposes documents from other tenants (#13229)

## What

Before this PR, an internal link in the Lexical editor could reference a
document from a different tenant than the active one.

Reproduction:
1. `pnpm dev plugin-multi-tenant`
2. Log in with `dev@payloadcms.com` and password `test`
3. Go to `http://localhost:3000/admin/collections/food-items` and switch
between the `Blue Dog` and `Steel Cat` tenants to see which food items
each tenant has.
4. Go to http://localhost:3000/admin/collections/food-items/create, and
in the new richtext field enter an internal link
5. In the relationship select menu, you will see the 6 food items at
once (3 of each of those tenants). In the relationship select menu, you
would previously see all 6 food items at once (3 from each of those
tenants). Now, you'll only see the 3 from the active tenant.

The new test verifies that this is fixed.

## How

`baseListFilter` is used, but now it's called `baseFilter` for obvious
reasons: it doesn't just filter the List View. Having two different
properties where the same function was supposed to be placed wasn't
feasible. `baseListFilter` is still supported for backwards
compatibility. It's used as a fallback if `baseFilter` isn't defined,
and it's documented as deprecated.

`baseFilter` is injected into `filterOptions` of the internal link field
in the Lexical Editor.
This commit is contained in:
German Jablonski
2025-08-07 11:24:15 -04:00
committed by GitHub
parent 161769e50c
commit 9c8f3202e4
17 changed files with 167 additions and 58 deletions

View File

@@ -142,7 +142,7 @@ The following options are available:
| `components` | Swap in your own React components to be used within this Collection. [More details](#custom-components). | | `components` | Swap in your own React components to be used within this Collection. [More details](#custom-components). |
| `listSearchableFields` | Specify which fields should be searched in the List search view. [More details](#list-searchable-fields). | | `listSearchableFields` | Specify which fields should be searched in the List search view. [More details](#list-searchable-fields). |
| `pagination` | Set pagination-specific options for this Collection. [More details](#pagination). | | `pagination` | Set pagination-specific options for this Collection. [More details](#pagination). |
| `baseListFilter` | You can define a default base filter for this collection's List view, which will be merged into any filters that the user performs. | | `baseFilter` | Defines a default base filter which will be applied to the List View (along with any other filters applied by the user) and internal links in Lexical Editor, |
<Banner type="warning"> <Banner type="warning">
**Note:** If you set `useAsTitle` to a relationship or join field, it will use **Note:** If you set `useAsTitle` to a relationship or join field, it will use

View File

@@ -76,7 +76,19 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
isGlobal?: boolean isGlobal?: boolean
/** /**
* Set to `false` if you want to manually apply * Set to `false` if you want to manually apply
* the baseListFilter * the baseFilter
*
* @default true
*/
useBaseFilter?: boolean
/**
* @deprecated Use `useBaseFilter` instead. If both are defined,
* `useBaseFilter` will take precedence. This property remains only
* for backward compatibility and may be removed in a future version.
*
* Originally, `baseListFilter` was intended to filter only the List View
* in the admin panel. However, base filtering is often required in other areas
* such as internal link relationships in the Lexical editor.
* *
* @default true * @default true
*/ */
@@ -203,12 +215,12 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/ */
useTenantsCollectionAccess?: boolean useTenantsCollectionAccess?: boolean
/** /**
* Opt out including the baseListFilter to filter * Opt out including the baseFilter to filter
* tenants by selected tenant * tenants by selected tenant
*/ */
useTenantsListFilter?: boolean useTenantsListFilter?: boolean
/** /**
* Opt out including the baseListFilter to filter * Opt out including the baseFilter to filter
* users by selected tenant * users by selected tenant
*/ */
useUsersTenantFilter?: boolean useUsersTenantFilter?: boolean

View File

@@ -138,16 +138,14 @@ export const renderListView = async (
throw new Error('not-found') throw new Error('not-found')
} }
let baseListFilter = undefined const baseFilterConstraint = await (
collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter
if (typeof collectionConfig.admin?.baseListFilter === 'function') { )?.({
baseListFilter = await collectionConfig.admin.baseListFilter({ limit: query.limit,
limit: query.limit, page: query.page,
page: query.page, req,
req, sort: query.sort,
sort: query.sort, })
})
}
let queryPreset: QueryPreset | undefined let queryPreset: QueryPreset | undefined
let queryPresetPermissions: SanitizedCollectionPermission | undefined let queryPresetPermissions: SanitizedCollectionPermission | undefined
@@ -155,7 +153,7 @@ export const renderListView = async (
let whereWithMergedSearch = mergeListSearchAndWhere({ let whereWithMergedSearch = mergeListSearchAndWhere({
collectionConfig, collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined, search: typeof query?.search === 'string' ? query.search : undefined,
where: combineWhereConstraints([query?.where, baseListFilter]), where: combineWhereConstraints([query?.where, baseFilterConstraint]),
}) })
if (trash === true) { if (trash === true) {

View File

@@ -29,7 +29,7 @@ export type ServerOnlyCollectionProperties = keyof Pick<
export type ServerOnlyCollectionAdminProperties = keyof Pick< export type ServerOnlyCollectionAdminProperties = keyof Pick<
SanitizedCollectionConfig['admin'], SanitizedCollectionConfig['admin'],
'baseListFilter' | 'components' | 'hidden' 'baseFilter' | 'baseListFilter' | 'components' | 'hidden'
> >
export type ServerOnlyUploadProperties = keyof Pick< export type ServerOnlyUploadProperties = keyof Pick<
@@ -94,6 +94,7 @@ const serverOnlyUploadProperties: Partial<ServerOnlyUploadProperties>[] = [
const serverOnlyCollectionAdminProperties: Partial<ServerOnlyCollectionAdminProperties>[] = [ const serverOnlyCollectionAdminProperties: Partial<ServerOnlyCollectionAdminProperties>[] = [
'hidden', 'hidden',
'baseFilter',
'baseListFilter', 'baseListFilter',
'components', 'components',
// 'preview' is handled separately // 'preview' is handled separately

View File

@@ -270,7 +270,7 @@ export type EnableFoldersOptions = {
debug?: boolean debug?: boolean
} }
export type BaseListFilter = (args: { export type BaseFilter = (args: {
limit: number limit: number
locale?: TypedLocale locale?: TypedLocale
page: number page: number
@@ -278,7 +278,31 @@ export type BaseListFilter = (args: {
sort: string sort: string
}) => null | Promise<null | Where> | Where }) => null | Promise<null | Where> | Where
/**
* @deprecated Use `BaseFilter` instead.
*/
export type BaseListFilter = BaseFilter
export type CollectionAdminOptions = { export type CollectionAdminOptions = {
/**
* Defines a default base filter which will be applied in the following parts of the admin panel:
* - List View
* - Relationship fields for internal links within the Lexical editor
*
* This is especially useful for plugins like multi-tenant. For example,
* a user may have access to multiple tenants, but should only see content
* related to the currently active or selected tenant in those places.
*/
baseFilter?: BaseFilter
/**
* @deprecated Use `baseFilter` instead. If both are defined,
* `baseFilter` will take precedence. This property remains only
* for backward compatibility and may be removed in a future version.
*
* Originally, `baseListFilter` was intended to filter only the List View
* in the admin panel. However, base filtering is often required in other areas
* such as internal link relationships in the Lexical editor.
*/
baseListFilter?: BaseListFilter baseListFilter?: BaseListFilter
/** /**
* Custom admin components * Custom admin components

View File

@@ -28,20 +28,20 @@ export async function buildFolderWhereConstraints({
}), }),
] ]
if (typeof collectionConfig.admin?.baseListFilter === 'function') { const baseFilterConstraint = await (
const baseListFilterConstraint = await collectionConfig.admin.baseListFilter({ collectionConfig.admin?.baseFilter ?? collectionConfig.admin?.baseListFilter
limit: 0, )?.({
locale: localeCode, limit: 0,
page: 1, locale: localeCode,
req, page: 1,
sort: req,
sort || sort:
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : 'id'), sort ||
}) (typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : 'id'),
})
if (baseListFilterConstraint) { if (baseFilterConstraint) {
constraints.push(baseListFilterConstraint) constraints.push(baseFilterConstraint)
}
} }
if (folderID) { if (folderID) {

View File

@@ -1168,6 +1168,7 @@ export type {
AfterRefreshHook as CollectionAfterRefreshHook, AfterRefreshHook as CollectionAfterRefreshHook,
AuthCollection, AuthCollection,
AuthOperationsFromCollectionSlug, AuthOperationsFromCollectionSlug,
BaseFilter,
BaseListFilter, BaseListFilter,
BeforeChangeHook as CollectionBeforeChangeHook, BeforeChangeHook as CollectionBeforeChangeHook,
BeforeDeleteHook as CollectionBeforeDeleteHook, BeforeDeleteHook as CollectionBeforeDeleteHook,

View File

@@ -36,11 +36,11 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/ */
isGlobal?: boolean isGlobal?: boolean
/** /**
* Set to `false` if you want to manually apply the baseListFilter * Set to `false` if you want to manually apply the baseFilter
* *
* @default true * @default true
*/ */
useBaseListFilter?: boolean useBaseFilter?: boolean
/** /**
* Set to `false` if you want to handle collection access manually without the multi-tenant constraints applied * Set to `false` if you want to handle collection access manually without the multi-tenant constraints applied
* *

View File

@@ -15,7 +15,7 @@ import { addTenantCleanup } from './hooks/afterTenantDelete.js'
import { translations } from './translations/index.js' import { translations } from './translations/index.js'
import { addCollectionAccess } from './utilities/addCollectionAccess.js' import { addCollectionAccess } from './utilities/addCollectionAccess.js'
import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js' import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js'
import { combineListFilters } from './utilities/combineListFilters.js' import { combineFilters } from './utilities/combineFilters.js'
export const multiTenantPlugin = export const multiTenantPlugin =
<ConfigType>(pluginConfig: MultiTenantPluginConfig<ConfigType>) => <ConfigType>(pluginConfig: MultiTenantPluginConfig<ConfigType>) =>
@@ -143,8 +143,10 @@ export const multiTenantPlugin =
adminUsersCollection.admin = {} adminUsersCollection.admin = {}
} }
adminUsersCollection.admin.baseListFilter = combineListFilters({ const baseFilter =
baseListFilter: adminUsersCollection.admin?.baseListFilter, adminUsersCollection.admin?.baseFilter ?? adminUsersCollection.admin?.baseListFilter
adminUsersCollection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) => customFilter: (args) =>
filterDocumentsByTenants({ filterDocumentsByTenants({
filterFieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`, filterFieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`,
@@ -207,8 +209,9 @@ export const multiTenantPlugin =
collection.admin = {} collection.admin = {}
} }
collection.admin.baseListFilter = combineListFilters({ const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter
baseListFilter: collection.admin?.baseListFilter, collection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) => customFilter: (args) =>
filterDocumentsByTenants({ filterDocumentsByTenants({
filterFieldName: 'id', filterFieldName: 'id',
@@ -296,7 +299,9 @@ export const multiTenantPlugin =
}), }),
) )
if (pluginConfig.collections[collection.slug]?.useBaseListFilter !== false) { const { useBaseFilter, useBaseListFilter } = pluginConfig.collections[collection.slug] || {}
if (useBaseFilter ?? useBaseListFilter ?? true) {
/** /**
* Add list filter to enabled collections * Add list filter to enabled collections
* - filters results by selected tenant * - filters results by selected tenant
@@ -305,8 +310,9 @@ export const multiTenantPlugin =
collection.admin = {} collection.admin = {}
} }
collection.admin.baseListFilter = combineListFilters({ const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter
baseListFilter: collection.admin?.baseListFilter, collection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) => customFilter: (args) =>
filterDocumentsByTenants({ filterDocumentsByTenants({
filterFieldName: tenantFieldName, filterFieldName: tenantFieldName,

View File

@@ -30,7 +30,19 @@ export type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/ */
isGlobal?: boolean isGlobal?: boolean
/** /**
* Set to `false` if you want to manually apply the baseListFilter * Set to `false` if you want to manually apply the baseFilter
*
* @default true
*/
useBaseFilter?: boolean
/**
* @deprecated Use `useBaseFilter` instead. If both are defined,
* `useBaseFilter` will take precedence. This property remains only
* for backward compatibility and may be removed in a future version.
*
* Originally, `baseListFilter` was intended to filter only the List View
* in the admin panel. However, base filtering is often required in other areas
* such as internal link relationships in the Lexical editor.
* *
* @default true * @default true
*/ */

View File

@@ -1,24 +1,24 @@
import type { BaseListFilter, Where } from 'payload' import type { BaseFilter, Where } from 'payload'
type Args = { type Args = {
baseListFilter?: BaseListFilter baseFilter?: BaseFilter
customFilter: BaseListFilter customFilter: BaseFilter
} }
/** /**
* Combines a base list filter with a tenant list filter * Combines a base filter with a tenant list filter
* *
* Combines where constraints inside of an AND operator * Combines where constraints inside of an AND operator
*/ */
export const combineListFilters = export const combineFilters =
({ baseListFilter, customFilter }: Args): BaseListFilter => ({ baseFilter, customFilter }: Args): BaseFilter =>
async (args) => { async (args) => {
const filterConstraints = [] const filterConstraints = []
if (typeof baseListFilter === 'function') { if (typeof baseFilter === 'function') {
const baseListFilterResult = await baseListFilter(args) const baseFilterResult = await baseFilter(args)
if (baseListFilterResult) { if (baseFilterResult) {
filterConstraints.push(baseListFilterResult) filterConstraints.push(baseFilterResult)
} }
} }

View File

@@ -117,13 +117,23 @@ export const getBaseFields = (
type: 'relationship', type: 'relationship',
filterOptions: filterOptions:
!enabledCollections && !disabledCollections !enabledCollections && !disabledCollections
? ({ relationTo, user }) => { ? async ({ relationTo, req, user }) => {
const hidden = config.collections.find(({ slug }) => slug === relationTo)?.admin const admin = config.collections.find(({ slug }) => slug === relationTo)?.admin
.hidden
const hidden = admin?.hidden
if (typeof hidden === 'function' && hidden({ user } as { user: TypedUser })) { if (typeof hidden === 'function' && hidden({ user } as { user: TypedUser })) {
return false return false
} }
return true
const baseFilter = admin?.baseFilter ?? admin?.baseListFilter
return (
(await baseFilter?.({
limit: 0,
page: 1,
req,
sort: 'id',
})) ?? true
)
} }
: null, : null,
label: ({ t }) => t('fields:chooseDocumentToLink'), label: ({ t }) => t('fields:chooseDocumentToLink'),

View File

@@ -1499,6 +1499,6 @@ export interface Auth {
declare module 'payload' { declare module 'payload' {
// @ts-ignore // @ts-ignore
export interface GeneratedTypes extends Config {} export interface GeneratedTypes extends Config {}
} }

View File

@@ -90,5 +90,9 @@ export const MenuItems: CollectionConfig = {
type: 'text', type: 'text',
required: true, required: true,
}, },
{
name: 'content',
type: 'richText',
},
], ],
} }

View File

@@ -11,4 +11,4 @@ export const credentials = {
email: 'owner@anchorAndBlueDog.com', email: 'owner@anchorAndBlueDog.com',
password: 'test', password: 'test',
}, },
} } as const

View File

@@ -311,6 +311,31 @@ test.describe('Multi Tenant', () => {
confirmationModal.getByText('You are about to change ownership from Blue Dog to Steel Cat'), confirmationModal.getByText('You are about to change ownership from Blue Dog to Steel Cat'),
).toBeVisible() ).toBeVisible()
}) })
test('should filter internal links in Lexical editor', async () => {
await loginClientSide({
page,
serverURL,
data: credentials.admin,
})
await selectTenant({
page,
tenant: 'Blue Dog',
})
await page.goto(menuItemsURL.create)
const editor = page.locator('[data-lexical-editor="true"]')
await editor.focus()
await page.keyboard.type('Hello World')
await page.keyboard.down('Shift')
for (let i = 0; i < 'World'.length; i++) {
await page.keyboard.press('ArrowLeft')
}
await page.keyboard.up('Shift')
await page.locator('.toolbar-popup__button-link').click()
await page.locator('.radio-input__styled-radio').last().click()
await page.locator('.drawer__content').locator('.rs__input').click()
await expect(page.getByText('Chorizo Con Queso')).toBeVisible()
await expect(page.getByText('Pretzel Bites')).toBeHidden()
})
}) })
test.describe('Globals', () => { test.describe('Globals', () => {

View File

@@ -177,6 +177,21 @@ export interface FoodItem {
id: string; id: string;
tenant?: (string | null) | Tenant; tenant?: (string | null) | Tenant;
name: string; name: string;
content?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -315,6 +330,7 @@ export interface UsersSelect<T extends boolean = true> {
export interface FoodItemsSelect<T extends boolean = true> { export interface FoodItemsSelect<T extends boolean = true> {
tenant?: T; tenant?: T;
name?: T; name?: T;
content?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }