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). |
| `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). |
| `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">
**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
/**
* 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
*/
@@ -203,12 +215,12 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/
useTenantsCollectionAccess?: boolean
/**
* Opt out including the baseListFilter to filter
* Opt out including the baseFilter to filter
* tenants by selected tenant
*/
useTenantsListFilter?: boolean
/**
* Opt out including the baseListFilter to filter
* Opt out including the baseFilter to filter
* users by selected tenant
*/
useUsersTenantFilter?: boolean

View File

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

View File

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

View File

@@ -270,7 +270,7 @@ export type EnableFoldersOptions = {
debug?: boolean
}
export type BaseListFilter = (args: {
export type BaseFilter = (args: {
limit: number
locale?: TypedLocale
page: number
@@ -278,7 +278,31 @@ export type BaseListFilter = (args: {
sort: string
}) => null | Promise<null | Where> | Where
/**
* @deprecated Use `BaseFilter` instead.
*/
export type BaseListFilter = BaseFilter
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
/**
* Custom admin components

View File

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

View File

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

View File

@@ -36,11 +36,11 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/
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
*/
useBaseListFilter?: boolean
useBaseFilter?: boolean
/**
* 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 { addCollectionAccess } from './utilities/addCollectionAccess.js'
import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js'
import { combineListFilters } from './utilities/combineListFilters.js'
import { combineFilters } from './utilities/combineFilters.js'
export const multiTenantPlugin =
<ConfigType>(pluginConfig: MultiTenantPluginConfig<ConfigType>) =>
@@ -143,8 +143,10 @@ export const multiTenantPlugin =
adminUsersCollection.admin = {}
}
adminUsersCollection.admin.baseListFilter = combineListFilters({
baseListFilter: adminUsersCollection.admin?.baseListFilter,
const baseFilter =
adminUsersCollection.admin?.baseFilter ?? adminUsersCollection.admin?.baseListFilter
adminUsersCollection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) =>
filterDocumentsByTenants({
filterFieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`,
@@ -207,8 +209,9 @@ export const multiTenantPlugin =
collection.admin = {}
}
collection.admin.baseListFilter = combineListFilters({
baseListFilter: collection.admin?.baseListFilter,
const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter
collection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) =>
filterDocumentsByTenants({
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
* - filters results by selected tenant
@@ -305,8 +310,9 @@ export const multiTenantPlugin =
collection.admin = {}
}
collection.admin.baseListFilter = combineListFilters({
baseListFilter: collection.admin?.baseListFilter,
const baseFilter = collection.admin?.baseFilter ?? collection.admin?.baseListFilter
collection.admin.baseFilter = combineFilters({
baseFilter,
customFilter: (args) =>
filterDocumentsByTenants({
filterFieldName: tenantFieldName,

View File

@@ -30,7 +30,19 @@ export type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/
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
*/

View File

@@ -1,24 +1,24 @@
import type { BaseListFilter, Where } from 'payload'
import type { BaseFilter, Where } from 'payload'
type Args = {
baseListFilter?: BaseListFilter
customFilter: BaseListFilter
baseFilter?: BaseFilter
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
*/
export const combineListFilters =
({ baseListFilter, customFilter }: Args): BaseListFilter =>
export const combineFilters =
({ baseFilter, customFilter }: Args): BaseFilter =>
async (args) => {
const filterConstraints = []
if (typeof baseListFilter === 'function') {
const baseListFilterResult = await baseListFilter(args)
if (typeof baseFilter === 'function') {
const baseFilterResult = await baseFilter(args)
if (baseListFilterResult) {
filterConstraints.push(baseListFilterResult)
if (baseFilterResult) {
filterConstraints.push(baseFilterResult)
}
}

View File

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

View File

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

View File

@@ -11,4 +11,4 @@ export const credentials = {
email: 'owner@anchorAndBlueDog.com',
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'),
).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', () => {

View File

@@ -177,6 +177,21 @@ export interface FoodItem {
id: string;
tenant?: (string | null) | Tenant;
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;
createdAt: string;
}
@@ -315,6 +330,7 @@ export interface UsersSelect<T extends boolean = true> {
export interface FoodItemsSelect<T extends boolean = true> {
tenant?: T;
name?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
}