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 https://github.com/payloadcms/payload/issues/10882
This commit is contained in:
Jarrod Flesch
2025-01-30 14:49:19 -05:00
committed by GitHub
parent 85c0842444
commit be790a9de2
8 changed files with 106 additions and 13 deletions

View File

@@ -25,6 +25,18 @@ This plugin sets up multi-tenancy for your application from within your [Admin P
- Adds a `tenant` field to each specified collection
- Adds a tenant selector to the admin panel, allowing you to switch between tenants
- Filters list view results by selected tenant
- Filters relationship fields by selected tenant
- Ability to create "global" like collections, 1 doc per tenant
- Automatically assign a tenant to new documents
<Banner type="error">
**Warning**
By default this plugin cleans up documents when a tenant is deleted. You should ensure you have
strong access control on your tenants collection to prevent deletions by unauthorized users.
You can disabled this behavior by setting `cleanupAfterTenantDelete` to `false` in the plugin options.
</Banner>
## Installation
@@ -40,7 +52,7 @@ The plugin accepts an object with the following properties:
```ts
type MultiTenantPluginConfig<ConfigTypes = unknown> = {
/**
/**
* After a tenant is deleted, the plugin will attempt to clean up related documents
* - removing documents with the tenant ID
* - removing the tenant from users
@@ -144,8 +156,12 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
* Useful for super-admin type users
*/
userHasAccessToAllTenants?: (
user: ConfigTypes extends { user } ? ConfigTypes['user'] : User,
user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User,
) => boolean
/**
* Opt out of adding access constraints to the tenants collection
*/
useTenantsCollectionAccess?: boolean
}
```

View File

@@ -128,6 +128,7 @@ export const multiTenantPlugin =
if (collection.slug === tenantsCollectionSlug) {
tenantCollection = collection
if (pluginConfig.useTenantsCollectionAccess !== false) {
/**
* Add access control constraint to tenants collection
* - constrains access a users assigned tenants
@@ -137,6 +138,7 @@ export const multiTenantPlugin =
fieldName: 'id',
userHasAccessToAllTenants,
})
}
if (pluginConfig.cleanupAfterTenantDelete !== false) {
/**

View File

@@ -121,6 +121,10 @@ export type MultiTenantPluginConfig<ConfigTypes = unknown> = {
userHasAccessToAllTenants?: (
user: ConfigTypes extends { user: unknown } ? ConfigTypes['user'] : User,
) => boolean
/**
* Opt out of adding access constraints to the tenants collection
*/
useTenantsCollectionAccess?: boolean
}
export type Tenant<IDType = number | string> = {

View File

@@ -79,6 +79,10 @@ const RichTextComponent: React.FC<
const disabled = readOnlyFromProps || formProcessing || formInitializing
const [isSmallWidthViewport, setIsSmallWidthViewport] = useState<boolean>(false)
const [rerenderProviderKey, setRerenderProviderKey] = useState<Date>()
const prevInitialValueRef = React.useRef<SerializedEditorState | undefined>(initialValue)
const prevValueRef = React.useRef<SerializedEditorState | undefined>(value)
useEffect(() => {
const updateViewPortWidth = () => {
@@ -113,13 +117,24 @@ const RichTextComponent: React.FC<
const handleChange = useCallback(
(editorState: EditorState) => {
setValue(editorState.toJSON())
const newState = editorState.toJSON()
prevValueRef.current = newState
setValue(newState)
},
[setValue],
)
const styles = useMemo(() => mergeFieldStyles(field), [field])
useEffect(() => {
if (JSON.stringify(initialValue) !== JSON.stringify(prevInitialValueRef.current)) {
prevInitialValueRef.current = initialValue
if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) {
setRerenderProviderKey(new Date())
}
}
}, [initialValue, value])
return (
<div className={classes} key={pathWithEditDepth} style={styles}>
<RenderCustomComponent
@@ -135,7 +150,7 @@ const RichTextComponent: React.FC<
editorConfig={editorConfig}
fieldProps={props}
isSmallWidthViewport={isSmallWidthViewport}
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)
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)
onChange={handleChange}
readOnly={disabled}
value={value}

View File

@@ -33,6 +33,7 @@ export const mergeServerFormState = ({
if (acceptValues) {
serverPropsToAccept.push('value')
serverPropsToAccept.push('initialValue')
}
let changed = false

View File

@@ -0,0 +1,28 @@
'use client'
import { useForm } from '@payloadcms/ui'
import React from 'react'
export const ClearState = ({ fieldName }: { fieldName: string }) => {
const { dispatchFields, fields } = useForm()
const clearState = React.useCallback(() => {
dispatchFields({
type: 'REPLACE_STATE',
state: {
...fields,
[fieldName]: {
...fields[fieldName],
initialValue: null,
value: null,
},
},
})
}, [dispatchFields, fields, fieldName])
return (
<button id={`clear-lexical-${fieldName}`} onClick={clearState} type="button">
Clear State
</button>
)
}

View File

@@ -264,6 +264,19 @@ describe('lexicalMain', () => {
})
})
test('should be able to externally mutate editor state', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(1).locator('.editor-scroller') // first
await expect(richTextField).toBeVisible()
await richTextField.click() // Use click, because focus does not work
await page.keyboard.type('some text')
const spanInEditor = richTextField.locator('span').first()
await expect(spanInEditor).toHaveText('some text')
await saveDocAndAssert(page)
await page.locator('#clear-lexical-lexicalSimple').click()
await expect(spanInEditor).not.toBeAttached()
})
test('should be able to bold text using floating select toolbar', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(2) // second

View File

@@ -317,6 +317,20 @@ export const LexicalFields: CollectionConfig = {
],
}),
},
{
type: 'ui',
name: 'clearLexicalState',
admin: {
components: {
Field: {
path: '/collections/Lexical/components/ClearState.js#ClearState',
clientProps: {
fieldName: 'lexicalSimple',
},
},
},
},
},
{
name: 'lexicalWithBlocks',
type: 'richText',