Skip to content

Commit be790a9

Browse files
feat(plugin-multi-tenant): allow opting out of tenant access control merge (#10888)
### What? In some cases you may want to opt out of using the default access control that this plugin provides on the tenants collection. ### Why? Other collections are able to opt out of this already, but the tenants collection specifically was not configured with an opt out capability. ### How? Adds new property to the plugin config: `useTenantsCollectionAccess`. Setting this to `false` allows users to opt out and write their own access control functions without the plugin merging in its own constraints for the tenant collection. Fixes #10882
1 parent 85c0842 commit be790a9

File tree

8 files changed

+106
-13
lines changed

8 files changed

+106
-13
lines changed

docs/plugins/multi-tenant.mdx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ This plugin sets up multi-tenancy for your application from within your [Admin P
2525
- Adds a `tenant` field to each specified collection
2626
- Adds a tenant selector to the admin panel, allowing you to switch between tenants
2727
- Filters list view results by selected tenant
28+
- Filters relationship fields by selected tenant
29+
- Ability to create "global" like collections, 1 doc per tenant
30+
- Automatically assign a tenant to new documents
31+
32+
<Banner type="error">
33+
**Warning**
34+
35+
By default this plugin cleans up documents when a tenant is deleted. You should ensure you have
36+
strong access control on your tenants collection to prevent deletions by unauthorized users.
37+
38+
You can disabled this behavior by setting `cleanupAfterTenantDelete` to `false` in the plugin options.
39+
</Banner>
2840

2941
## Installation
3042

@@ -40,7 +52,7 @@ The plugin accepts an object with the following properties:
4052

4153
```ts
4254
type MultiTenantPluginConfig<ConfigTypes = unknown> = {
43-
/**
55+
/**
4456
* After a tenant is deleted, the plugin will attempt to clean up related documents
4557
* - removing documents with the tenant ID
4658
* - removing the tenant from users
@@ -144,8 +156,12 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
144156
* Useful for super-admin type users
145157
*/
146158
userHasAccessToAllTenants?: (
147-
user: ConfigTypes extends { user } ? ConfigTypes['user'] : User,
159+
user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User,
148160
) => boolean
161+
/**
162+
* Opt out of adding access constraints to the tenants collection
163+
*/
164+
useTenantsCollectionAccess?: boolean
149165
}
150166
```
151167

packages/plugin-multi-tenant/src/index.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,17 @@ export const multiTenantPlugin =
128128
if (collection.slug === tenantsCollectionSlug) {
129129
tenantCollection = collection
130130

131-
/**
132-
* Add access control constraint to tenants collection
133-
* - constrains access a users assigned tenants
134-
*/
135-
addCollectionAccess({
136-
collection,
137-
fieldName: 'id',
138-
userHasAccessToAllTenants,
139-
})
131+
if (pluginConfig.useTenantsCollectionAccess !== false) {
132+
/**
133+
* Add access control constraint to tenants collection
134+
* - constrains access a users assigned tenants
135+
*/
136+
addCollectionAccess({
137+
collection,
138+
fieldName: 'id',
139+
userHasAccessToAllTenants,
140+
})
141+
}
140142

141143
if (pluginConfig.cleanupAfterTenantDelete !== false) {
142144
/**

packages/plugin-multi-tenant/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ export type MultiTenantPluginConfig<ConfigTypes = unknown> = {
121121
userHasAccessToAllTenants?: (
122122
user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User,
123123
) => boolean
124+
/**
125+
* Opt out of adding access constraints to the tenants collection
126+
*/
127+
useTenantsCollectionAccess?: boolean
124128
}
125129

126130
export type Tenant<IDType = number | string> = {

packages/richtext-lexical/src/field/Field.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ const RichTextComponent: React.FC<
7979
const disabled = readOnlyFromProps || formProcessing || formInitializing
8080

8181
const [isSmallWidthViewport, setIsSmallWidthViewport] = useState<boolean>(false)
82+
const [rerenderProviderKey, setRerenderProviderKey] = useState<Date>()
83+
84+
const prevInitialValueRef = React.useRef<SerializedEditorState | undefined>(initialValue)
85+
const prevValueRef = React.useRef<SerializedEditorState | undefined>(value)
8286

8387
useEffect(() => {
8488
const updateViewPortWidth = () => {
@@ -113,13 +117,24 @@ const RichTextComponent: React.FC<
113117

114118
const handleChange = useCallback(
115119
(editorState: EditorState) => {
116-
setValue(editorState.toJSON())
120+
const newState = editorState.toJSON()
121+
prevValueRef.current = newState
122+
setValue(newState)
117123
},
118124
[setValue],
119125
)
120126

121127
const styles = useMemo(() => mergeFieldStyles(field), [field])
122128

129+
useEffect(() => {
130+
if (JSON.stringify(initialValue) !== JSON.stringify(prevInitialValueRef.current)) {
131+
prevInitialValueRef.current = initialValue
132+
if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) {
133+
setRerenderProviderKey(new Date())
134+
}
135+
}
136+
}, [initialValue, value])
137+
123138
return (
124139
<div className={classes} key={pathWithEditDepth} style={styles}>
125140
<RenderCustomComponent
@@ -135,7 +150,7 @@ const RichTextComponent: React.FC<
135150
editorConfig={editorConfig}
136151
fieldProps={props}
137152
isSmallWidthViewport={isSmallWidthViewport}
138-
key={JSON.stringify({ initialValue, path })} // makes sure lexical is completely re-rendered when initialValue changes, bypassing the lexical-internal value memoization. That way, external changes to the form will update the editor. More infos in PR description (https://github.com/payloadcms/payload/pull/5010)
153+
key={JSON.stringify({ path, rerenderProviderKey })} // makes sure lexical is completely re-rendered when initialValue changes, bypassing the lexical-internal value memoization. That way, external changes to the form will update the editor. More infos in PR description (https://github.com/payloadcms/payload/pull/5010)
139154
onChange={handleChange}
140155
readOnly={disabled}
141156
value={value}

packages/ui/src/forms/Form/mergeServerFormState.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const mergeServerFormState = ({
3333

3434
if (acceptValues) {
3535
serverPropsToAccept.push('value')
36+
serverPropsToAccept.push('initialValue')
3637
}
3738

3839
let changed = false
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
'use client'
2+
3+
import { useForm } from '@payloadcms/ui'
4+
import React from 'react'
5+
6+
export const ClearState = ({ fieldName }: { fieldName: string }) => {
7+
const { dispatchFields, fields } = useForm()
8+
9+
const clearState = React.useCallback(() => {
10+
dispatchFields({
11+
type: 'REPLACE_STATE',
12+
state: {
13+
...fields,
14+
[fieldName]: {
15+
...fields[fieldName],
16+
initialValue: null,
17+
value: null,
18+
},
19+
},
20+
})
21+
}, [dispatchFields, fields, fieldName])
22+
23+
return (
24+
<button id={`clear-lexical-${fieldName}`} onClick={clearState} type="button">
25+
Clear State
26+
</button>
27+
)
28+
}

test/fields/collections/Lexical/e2e/main/e2e.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,19 @@ describe('lexicalMain', () => {
264264
})
265265
})
266266

267+
test('should be able to externally mutate editor state', async () => {
268+
await navigateToLexicalFields()
269+
const richTextField = page.locator('.rich-text-lexical').nth(1).locator('.editor-scroller') // first
270+
await expect(richTextField).toBeVisible()
271+
await richTextField.click() // Use click, because focus does not work
272+
await page.keyboard.type('some text')
273+
const spanInEditor = richTextField.locator('span').first()
274+
await expect(spanInEditor).toHaveText('some text')
275+
await saveDocAndAssert(page)
276+
await page.locator('#clear-lexical-lexicalSimple').click()
277+
await expect(spanInEditor).not.toBeAttached()
278+
})
279+
267280
test('should be able to bold text using floating select toolbar', async () => {
268281
await navigateToLexicalFields()
269282
const richTextField = page.locator('.rich-text-lexical').nth(2) // second

test/fields/collections/Lexical/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,20 @@ export const LexicalFields: CollectionConfig = {
317317
],
318318
}),
319319
},
320+
{
321+
type: 'ui',
322+
name: 'clearLexicalState',
323+
admin: {
324+
components: {
325+
Field: {
326+
path: '/collections/Lexical/components/ClearState.js#ClearState',
327+
clientProps: {
328+
fieldName: 'lexicalSimple',
329+
},
330+
},
331+
},
332+
},
333+
},
320334
{
321335
name: 'lexicalWithBlocks',
322336
type: 'richText',

0 commit comments

Comments
 (0)