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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1168,6 +1168,7 @@ export type {
|
||||
AfterRefreshHook as CollectionAfterRefreshHook,
|
||||
AuthCollection,
|
||||
AuthOperationsFromCollectionSlug,
|
||||
BaseFilter,
|
||||
BaseListFilter,
|
||||
BeforeChangeHook as CollectionBeforeChangeHook,
|
||||
BeforeDeleteHook as CollectionBeforeDeleteHook,
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -90,5 +90,9 @@ export const MenuItems: CollectionConfig = {
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -11,4 +11,4 @@ export const credentials = {
|
||||
email: 'owner@anchorAndBlueDog.com',
|
||||
password: 'test',
|
||||
},
|
||||
}
|
||||
} as const
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user