Skip to content

Commit 3025377

Browse files
authored
fix(richtext-lexical): blocksFeature with relationship exposes other tenants (#14985)
Fixes #14823 The issue is essentially the same problem that PR #13229 fixed, but for a different Lexical feature: | PR #13229 | PR #14985 (this one) | | --- | --- | | Link Feature with internal links | BlocksFeature with relationship fields inside blocks | | Relationship to documents exposes other tenants | Relationship inside blocks exposes other tenants | ## Root Cause The multi tenant plugin applies tenant filters to relationship fields using `addFilterOptionsToFields()`, which only runs on collection fields. However: - BlocksFeature defines blocks via feature props rather than collection fields - These blocks are processed independently through `sanitizeFields()` - Relationship fields inside blocks never receive the collection baseFilter
1 parent f111624 commit 3025377

4 files changed

Lines changed: 179 additions & 2 deletions

File tree

packages/richtext-lexical/src/features/blocks/server/index.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99

1010
import { fieldsToJSONSchema, flattenAllFields, sanitizeFields } from 'payload'
1111

12+
import { applyBaseFilterToFields } from '../../../utilities/applyBaseFilterToFields.js'
1213
import { createServerFeature } from '../../../utilities/createServerFeature.js'
1314
import { createNode } from '../../typeUtilities.js'
1415
import { blockPopulationPromiseHOC } from './graphQLPopulationPromise.js'
@@ -58,7 +59,11 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
5859
`Block not found for slug: ${typeof _block === 'string' ? _block : _block?.slug}`,
5960
)
6061
}
61-
blockConfigs.push(block)
62+
// Apply baseFilter to relationship fields in the block
63+
blockConfigs.push({
64+
...block,
65+
fields: applyBaseFilterToFields(block.fields, _config),
66+
})
6267
}
6368

6469
const inlineBlockConfigs: Block[] = []
@@ -71,7 +76,11 @@ export const BlocksFeature = createServerFeature<BlocksFeatureProps, BlocksFeatu
7176
`Block not found for slug: ${typeof _block === 'string' ? _block : _block?.slug}`,
7277
)
7378
}
74-
inlineBlockConfigs.push(block)
79+
// Apply baseFilter to relationship fields in the block
80+
inlineBlockConfigs.push({
81+
...block,
82+
fields: applyBaseFilterToFields(block.fields, _config),
83+
})
7584
}
7685

7786
return {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { Block, Field, SanitizedConfig, TypedUser } from 'payload'
2+
3+
import { combineWhereConstraints } from 'payload/shared'
4+
5+
/**
6+
* Recursively applies baseFilter from collection config to relationship fields
7+
* within blocks. This ensures that relationship drawers in blocks respect
8+
* collection-level filters like multi-tenant filtering.
9+
*
10+
* Based on the fix from PR #13229 for LinkFeature
11+
*/
12+
export function applyBaseFilterToFields(fields: Field[], config: SanitizedConfig): Field[] {
13+
return fields.map((field) => {
14+
// Handle relationship fields
15+
if (field.type === 'relationship') {
16+
const relationshipField = field
17+
18+
// Store the original filterOptions
19+
const originalFilterOptions = relationshipField.filterOptions
20+
21+
// Create new filterOptions that includes baseFilter
22+
relationshipField.filterOptions = async (args) => {
23+
const { relationTo, req, user } = args
24+
25+
// Call original filterOptions if it exists
26+
const originalResult =
27+
typeof originalFilterOptions === 'function'
28+
? await originalFilterOptions(args)
29+
: (originalFilterOptions ?? true)
30+
31+
// If original filter returns false, respect that
32+
if (originalResult === false) {
33+
return false
34+
}
35+
36+
// Get the collection's admin config
37+
const admin = config.collections.find(({ slug }) => slug === relationTo)?.admin
38+
39+
// Check if collection is hidden
40+
const hidden = admin?.hidden
41+
if (typeof hidden === 'function' && hidden({ user } as { user: TypedUser })) {
42+
return false
43+
}
44+
45+
// Apply baseFilter (with backwards compatibility for baseListFilter)
46+
const baseFilter = admin?.baseFilter ?? admin?.baseListFilter
47+
const baseFilterResult = await baseFilter?.({
48+
limit: 0,
49+
page: 1,
50+
req,
51+
sort: 'id',
52+
})
53+
54+
// If no baseFilter, return original result
55+
if (!baseFilterResult) {
56+
return originalResult
57+
}
58+
59+
// If original result is true, just return the baseFilter
60+
if (originalResult === true) {
61+
return baseFilterResult
62+
}
63+
64+
// Combine original and baseFilter results
65+
return combineWhereConstraints([originalResult, baseFilterResult], 'and')
66+
}
67+
68+
return relationshipField
69+
}
70+
71+
// Recursively process nested fields
72+
if ('fields' in field && field.fields) {
73+
return {
74+
...field,
75+
fields: applyBaseFilterToFields(field.fields, config),
76+
}
77+
}
78+
79+
// Handle tabs
80+
if (field.type === 'tabs' && 'tabs' in field) {
81+
return {
82+
...field,
83+
tabs: field.tabs.map((tab) => ({
84+
...tab,
85+
fields: applyBaseFilterToFields(tab.fields, config),
86+
})),
87+
}
88+
}
89+
90+
// Handle blocks
91+
if (field.type === 'blocks') {
92+
const blocks = (field.blockReferences ?? field.blocks ?? []) as Block[]
93+
return {
94+
...field,
95+
blocks: blocks.map((block) => {
96+
if (typeof block === 'string') {
97+
return block
98+
}
99+
return {
100+
...block,
101+
fields: applyBaseFilterToFields(block.fields, config),
102+
}
103+
}),
104+
}
105+
}
106+
107+
return field
108+
})
109+
}

test/plugin-multi-tenant/collections/MenuItems.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Access, CollectionConfig, Where } from 'payload'
22

33
import { getUserTenantIDs } from '@payloadcms/plugin-multi-tenant/utilities'
4+
import { BlocksFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
45

56
import { menuItemsSlug, notTenantedSlug, relationshipsSlug } from '../shared.js'
67

@@ -96,6 +97,25 @@ export const MenuItems: CollectionConfig = {
9697
{
9798
name: 'content',
9899
type: 'richText',
100+
editor: lexicalEditor({
101+
features: ({ defaultFeatures }) => [
102+
...defaultFeatures,
103+
BlocksFeature({
104+
blocks: [
105+
{
106+
slug: 'block-with-relationship',
107+
fields: [
108+
{
109+
name: 'relationship',
110+
type: 'relationship',
111+
relationTo: 'food-menu',
112+
},
113+
],
114+
},
115+
],
116+
}),
117+
],
118+
}),
99119
},
100120
{
101121
name: 'polymorphicRelationship',

test/plugin-multi-tenant/e2e.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,45 @@ test.describe('Multi Tenant', () => {
416416
await expect(page.getByText('Chorizo Con Queso')).toBeVisible()
417417
await expect(page.getByText('Pretzel Bites')).toBeHidden()
418418
})
419+
420+
test('should filter relationship fields in Lexical BlocksFeature', async () => {
421+
await loginClientSide({
422+
data: credentials.admin,
423+
page,
424+
serverURL,
425+
})
426+
await page.goto(menuItemsURL.create)
427+
await selectDocumentTenant({
428+
page,
429+
payload,
430+
tenant: 'Blue Dog',
431+
})
432+
433+
// Fill in the required name field
434+
await page.fill('#field-name', 'Test Menu Item')
435+
436+
// Find the bug-repro richtext field and insert a block
437+
const rte = page.locator('.rich-text-lexical [data-lexical-editor="true"]')
438+
await rte.click()
439+
await rte.focus()
440+
441+
// Open slash menu and insert block
442+
await page.keyboard.type('/')
443+
await expect(page.locator('.slash-menu-popup')).toBeVisible()
444+
await page.getByText('Block With Relationship').click()
445+
446+
// Wait for block to be inserted
447+
await expect(page.locator('.LexicalEditorTheme__block')).toBeVisible()
448+
449+
// Open the relationship field in the block
450+
await page.locator('.LexicalEditorTheme__block .rs__input').click()
451+
452+
// Should only show Blue Dog Menu, not Steel Cat Menu or others
453+
await expect(page.getByText('Blue Dog Menu')).toBeVisible()
454+
await expect(page.getByText('Steel Cat Menu')).toBeHidden()
455+
await expect(page.getByText('Anchor Bar Menu')).toBeHidden()
456+
await expect(page.locator('.rs__menu')).toHaveCount(1)
457+
})
419458
})
420459

421460
test.describe('Globals', () => {

0 commit comments

Comments
 (0)