feat(plugin-multi-tenant): visible tenant field on documents (#13379)

The goal of this PR is to show the selected tenant on the document level
instead of using the global selector to sync the state to the document.


Should merge https://github.com/payloadcms/payload/pull/13316 before
this one.

### Video of what this PR implements

**Would love feedback!**


https://github.com/user-attachments/assets/93ca3d2c-d479-4555-ab38-b77a5a9955e8
This commit is contained in:
Jarrod Flesch
2025-08-21 13:15:24 -04:00
committed by GitHub
parent 393b4a0929
commit 5cf215d9cb
24 changed files with 839 additions and 380 deletions

View File

@@ -85,8 +85,8 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
*/
tenantFieldOverrides?: CollectionTenantFieldConfigOverrides
/**
* Set to `false` if you want to manually apply the baseListFilter
* Set to `false` if you want to manually apply the baseFilter
* Set to `false` if you want to manually apply
* the baseFilter
*
* @default true
*/

View File

@@ -26,9 +26,11 @@ export const LogoutClient: React.FC<{
const { startRouteTransition } = useRouteTransition()
const [isLoggedOut, setIsLoggedOut] = React.useState<boolean>(!user)
const isLoggedIn = React.useMemo(() => {
return Boolean(user?.id)
}, [user?.id])
const logOutSuccessRef = React.useRef(false)
const navigatingToLoginRef = React.useRef(false)
const [loginRoute] = React.useState(() =>
formatAdminURL({
@@ -45,26 +47,26 @@ export const LogoutClient: React.FC<{
const router = useRouter()
const handleLogOut = React.useCallback(async () => {
const loggedOut = await logOut()
setIsLoggedOut(loggedOut)
await logOut()
if (!inactivity && loggedOut && !logOutSuccessRef.current) {
if (!inactivity && !navigatingToLoginRef.current) {
toast.success(t('authentication:loggedOutSuccessfully'))
logOutSuccessRef.current = true
navigatingToLoginRef.current = true
startRouteTransition(() => router.push(loginRoute))
return
}
}, [inactivity, logOut, loginRoute, router, startRouteTransition, t])
useEffect(() => {
if (!isLoggedOut) {
if (isLoggedIn) {
void handleLogOut()
} else {
} else if (!navigatingToLoginRef.current) {
navigatingToLoginRef.current = true
startRouteTransition(() => router.push(loginRoute))
}
}, [handleLogOut, isLoggedOut, loginRoute, router, startRouteTransition])
}, [handleLogOut, isLoggedIn, loginRoute, router, startRouteTransition])
if (isLoggedOut && inactivity) {
if (!isLoggedIn && inactivity) {
return (
<div className={`${baseClass}__wrap`}>
<h2>{t('authentication:loggedOutInactivity')}</h2>

View File

@@ -1,12 +1,27 @@
'use client'
import type { RelationshipFieldClientProps } from 'payload'
import type { RelationshipFieldClientProps, StaticLabel } from 'payload'
import { RelationshipField, useField, useFormModified } from '@payloadcms/ui'
import { getTranslation } from '@payloadcms/translations'
import {
ConfirmationModal,
RelationshipField,
Translation,
useField,
useForm,
useFormModified,
useModal,
useTranslation,
} from '@payloadcms/ui'
import React from 'react'
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
import type {
PluginMultiTenantTranslationKeys,
PluginMultiTenantTranslations,
} from '../../translations/index.js'
import './index.scss'
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
const baseClass = 'tenantField'
@@ -16,62 +31,172 @@ type Props = {
} & RelationshipFieldClientProps
export const TenantField = (args: Props) => {
const { debug, unique } = args
const { setValue, value } = useField<number | string>()
const modified = useFormModified()
const {
options,
selectedTenantID,
setEntityType: setEntityType,
setModified,
setTenant,
} = useTenantSelection()
const hasSetValueRef = React.useRef(false)
const { entityType, options, selectedTenantID, setEntityType, setTenant } = useTenantSelection()
const { value } = useField<number | string>()
React.useEffect(() => {
if (!hasSetValueRef.current) {
// set value on load
if (value && value !== selectedTenantID) {
setTenant({ id: value, refresh: unique })
} else {
// in the document view, the tenant field should always have a value
const defaultValue = selectedTenantID || options[0]?.value
setTenant({ id: defaultValue, refresh: unique })
if (!entityType) {
setEntityType(args.unique ? 'global' : 'document')
} else {
// unique documents are controlled from the global TenantSelector
if (!args.unique && value) {
if (!selectedTenantID || value !== selectedTenantID) {
setTenant({ id: value, refresh: false })
}
}
hasSetValueRef.current = true
} else if (!value || value !== selectedTenantID) {
// Update the field on the document value when the tenant is changed
setValue(selectedTenantID, !value || value === selectedTenantID)
}
}, [value, selectedTenantID, setTenant, setValue, options, unique])
React.useEffect(() => {
setEntityType(unique ? 'global' : 'document')
return () => {
setEntityType(undefined)
}
}, [unique, setEntityType])
React.useEffect(() => {
// sync form modified state with the tenant selection provider context
setModified(modified)
return () => {
setModified(false)
if (entityType) {
setEntityType(undefined)
}
}
}, [modified, setModified])
}, [args.unique, options, selectedTenantID, setTenant, value, setEntityType, entityType])
if (debug) {
if (options.length > 1) {
return (
<div className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<RelationshipField {...args} />
<>
<div className={baseClass}>
<div className={`${baseClass}__wrapper`}>
<RelationshipField
{...args}
field={{
...args.field,
required: true,
}}
readOnly={args.readOnly || args.unique}
/>
</div>
</div>
<div className={`${baseClass}__hr`} />
</div>
{args.unique ? (
<SyncFormModified />
) : (
<ConfirmTenantChange fieldLabel={args.field.label} fieldPath={args.path} />
)}
</>
)
}
return null
}
const confirmSwitchTenantSlug = 'confirm-switch-tenant'
const ConfirmTenantChange = ({
fieldLabel,
fieldPath,
}: {
fieldLabel?: StaticLabel
fieldPath: string
}) => {
const { options, selectedTenantID, setTenant } = useTenantSelection()
const { setValue: setTenantFormValue, value: tenantFormValue } = useField<null | number | string>(
{ path: fieldPath },
)
const { setModified } = useForm()
const modified = useFormModified()
const { i18n, t } = useTranslation<
PluginMultiTenantTranslations,
PluginMultiTenantTranslationKeys
>()
const { isModalOpen, openModal } = useModal()
const prevTenantValueRef = React.useRef<null | number | string>(tenantFormValue || null)
const [tenantToConfirm, setTenantToConfirm] = React.useState<null | number | string>(
tenantFormValue || null,
)
const fromTenantOption = React.useMemo(() => {
if (tenantFormValue) {
return options.find((option) => option.value === tenantFormValue)
}
return undefined
}, [options, tenantFormValue])
const toTenantOption = React.useMemo(() => {
if (tenantToConfirm) {
return options.find((option) => option.value === tenantToConfirm)
}
return undefined
}, [options, tenantToConfirm])
const modalIsOpen = isModalOpen(confirmSwitchTenantSlug)
const testRef = React.useRef<boolean>(false)
React.useEffect(() => {
// the form value changed
if (
!modalIsOpen &&
tenantFormValue &&
prevTenantValueRef.current &&
tenantFormValue !== prevTenantValueRef.current
) {
// revert the form value change temporarily
setTenantFormValue(prevTenantValueRef.current, true)
// save the tenant to confirm in modal
setTenantToConfirm(tenantFormValue)
// open confirmation modal
openModal(confirmSwitchTenantSlug)
}
}, [
tenantFormValue,
setTenantFormValue,
openModal,
setTenant,
selectedTenantID,
modalIsOpen,
modified,
])
return (
<ConfirmationModal
body={
<Translation
elements={{
0: ({ children }) => {
return <b>{children}</b>
},
}}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
i18nKey="plugin-multi-tenant:confirm-modal-tenant-switch--body"
t={t}
variables={{
fromTenant: fromTenantOption?.label,
toTenant: toTenantOption?.label,
}}
/>
}
heading={t('plugin-multi-tenant:confirm-modal-tenant-switch--heading', {
tenantLabel: fieldLabel
? getTranslation(fieldLabel, i18n)
: t('plugin-multi-tenant:nav-tenantSelector-label'),
})}
modalSlug={confirmSwitchTenantSlug}
onCancel={() => {
setModified(testRef.current)
}}
onConfirm={() => {
// set the form value to the tenant to confirm
prevTenantValueRef.current = tenantToConfirm
setTenantFormValue(tenantToConfirm)
}}
/>
)
}
/**
* Tells the global selector when the form has been modified
* so it can display the "Leave without saving" confirmation modal
* if modified and attempting to change the tenant
*/
const SyncFormModified = () => {
const modified = useFormModified()
const { setModified } = useTenantSelection()
React.useEffect(() => {
setModified(modified)
}, [modified, setModified])
return null
}

View File

@@ -1,14 +1,48 @@
.tenantField {
&__wrapper {
margin-top: calc(-.75 * var(--spacing-field));
margin-bottom: var(--spacing-field);
width: 25%;
.document-fields__main {
--tenant-gutter-h-right: var(--main-gutter-h-right);
--tenant-gutter-h-left: var(--main-gutter-h-left);
}
.document-fields__sidebar-fields {
--tenant-gutter-h-right: var(--sidebar-gutter-h-right);
--tenant-gutter-h-left: var(--sidebar-gutter-h-left);
}
.document-fields__sidebar-fields,
.document-fields__main {
.render-fields {
.tenantField {
width: calc(100% + var(--tenant-gutter-h-right) + var(--tenant-gutter-h-left));
margin-left: calc(-1 * var(--tenant-gutter-h-left));
border-bottom: 1px solid var(--theme-elevation-100);
padding-top: calc(var(--base) * 1);
padding-bottom: calc(var(--base) * 1.75);
&__wrapper {
padding-left: var(--tenant-gutter-h-left);
padding-right: var(--tenant-gutter-h-right);
}
[dir='rtl'] & {
margin-right: calc(-1 * var(--tenant-gutter-h-right));
background-image: repeating-linear-gradient(
-120deg,
var(--theme-elevation-50) 0px,
var(--theme-elevation-50) 1px,
transparent 1px,
transparent 5px
);
}
&:not(:first-child) {
border-top: 1px solid var(--theme-elevation-100);
margin-top: calc(var(--base) * 1.25);
}
&:not(:last-child) {
margin-bottom: var(--spacing-field);
}
&:first-child {
margin-top: calc(var(--base) * -1.5);
padding-top: calc(var(--base) * 1.5);
}
}
}
&__hr {
width: calc(100% + 2 * var(--gutter-h));
margin-left: calc(-1 * var(--gutter-h));
background-color: var(--theme-elevation-100);
height: 1px;
margin-bottom: var(--spacing-field);
}
}
}

View File

@@ -0,0 +1,108 @@
'use client'
import type { ReactSelectOption } from '@payloadcms/ui'
import type { ViewTypes } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { ConfirmationModal, SelectInput, useModal, useTranslation } from '@payloadcms/ui'
import React from 'react'
import type {
PluginMultiTenantTranslationKeys,
PluginMultiTenantTranslations,
} from '../../translations/index.js'
import type { MultiTenantPluginConfig } from '../../types.js'
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
import './index.scss'
const confirmLeaveWithoutSavingSlug = 'confirm-leave-without-saving'
export const TenantSelectorClient = ({
disabled: disabledFromProps,
label,
viewType,
}: {
disabled?: boolean
label?: MultiTenantPluginConfig['tenantSelectorLabel']
viewType?: ViewTypes
}) => {
const { entityType, modified, options, selectedTenantID, setTenant } = useTenantSelection()
const { closeModal, openModal } = useModal()
const { i18n, t } = useTranslation<
PluginMultiTenantTranslations,
PluginMultiTenantTranslationKeys
>()
const [tenantSelection, setTenantSelection] = React.useState<
ReactSelectOption | ReactSelectOption[]
>()
const switchTenant = React.useCallback(
(option: ReactSelectOption | ReactSelectOption[] | undefined) => {
if (option && 'value' in option) {
setTenant({ id: option.value as string, refresh: true })
} else {
setTenant({ id: undefined, refresh: true })
}
},
[setTenant],
)
const onChange = React.useCallback(
(option: ReactSelectOption | ReactSelectOption[]) => {
if (option && 'value' in option && option.value === selectedTenantID) {
// If the selected option is the same as the current tenant, do nothing
return
}
if (entityType === 'global' && modified) {
// If the entityType is 'global' and there are unsaved changes, prompt for confirmation
setTenantSelection(option)
openModal(confirmLeaveWithoutSavingSlug)
} else {
// If the entityType is not 'document', switch tenant without confirmation
switchTenant(option)
}
},
[selectedTenantID, entityType, modified, switchTenant, openModal],
)
if (options.length <= 1) {
return null
}
return (
<div className="tenant-selector">
<SelectInput
isClearable={viewType === 'list'}
label={
label ? getTranslation(label, i18n) : t('plugin-multi-tenant:nav-tenantSelector-label')
}
name="setTenant"
onChange={onChange}
options={options}
path="setTenant"
readOnly={
disabledFromProps ||
(entityType !== 'global' &&
viewType &&
(['document', 'version'] satisfies ViewTypes[] as ViewTypes[]).includes(viewType))
}
value={selectedTenantID as string | undefined}
/>
<ConfirmationModal
body={t('general:changesNotSaved')}
cancelLabel={t('general:stayOnThisPage')}
confirmLabel={t('general:leaveAnyway')}
heading={t('general:leaveWithoutSaving')}
modalSlug={confirmLeaveWithoutSavingSlug}
onCancel={() => {
closeModal(confirmLeaveWithoutSavingSlug)
}}
onConfirm={() => {
switchTenant(tenantSelection)
}}
/>
</div>
)
}

View File

@@ -1,149 +1,22 @@
'use client'
import type { ReactSelectOption } from '@payloadcms/ui'
import type { ViewTypes } from 'payload'
import type { ServerProps } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import {
ConfirmationModal,
SelectInput,
Translation,
useModal,
useTranslation,
} from '@payloadcms/ui'
import React from 'react'
import type { MultiTenantPluginConfig } from '../../types.js'
import type {
PluginMultiTenantTranslationKeys,
PluginMultiTenantTranslations,
} from '../../translations/index.js'
import { TenantSelectorClient } from './index.client.js'
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.client.js'
import './index.scss'
const confirmSwitchTenantSlug = 'confirm-switch-tenant'
const confirmLeaveWithoutSavingSlug = 'confirm-leave-without-saving'
export const TenantSelector = ({ label, viewType }: { label: string; viewType?: ViewTypes }) => {
const { entityType, modified, options, selectedTenantID, setTenant } = useTenantSelection()
const { closeModal, openModal } = useModal()
const { i18n, t } = useTranslation<
PluginMultiTenantTranslations,
PluginMultiTenantTranslationKeys
>()
const [tenantSelection, setTenantSelection] = React.useState<
ReactSelectOption | ReactSelectOption[]
>()
const selectedValue = React.useMemo(() => {
if (selectedTenantID) {
return options.find((option) => option.value === selectedTenantID)
}
return undefined
}, [options, selectedTenantID])
const newSelectedValue = React.useMemo(() => {
if (tenantSelection && 'value' in tenantSelection) {
return options.find((option) => option.value === tenantSelection.value)
}
return undefined
}, [options, tenantSelection])
const switchTenant = React.useCallback(
(option: ReactSelectOption | ReactSelectOption[] | undefined) => {
if (option && 'value' in option) {
setTenant({ id: option.value as string, refresh: true })
} else {
setTenant({ id: undefined, refresh: true })
}
},
[setTenant],
type Props = {
enabledSlugs: string[]
label: MultiTenantPluginConfig['tenantSelectorLabel']
} & ServerProps
export const TenantSelector = (props: Props) => {
const { enabledSlugs, label, params, viewType } = props
const enabled = Boolean(
params?.segments &&
Array.isArray(params.segments) &&
params.segments[0] === 'collections' &&
params.segments[1] &&
enabledSlugs.includes(params.segments[1]),
)
const onChange = React.useCallback(
(option: ReactSelectOption | ReactSelectOption[]) => {
if (option && 'value' in option && option.value === selectedTenantID) {
// If the selected option is the same as the current tenant, do nothing
return
}
if (entityType !== 'document') {
if (entityType === 'global' && modified) {
// If the entityType is 'global' and there are unsaved changes, prompt for confirmation
setTenantSelection(option)
openModal(confirmLeaveWithoutSavingSlug)
} else {
// If the entityType is not 'document', switch tenant without confirmation
switchTenant(option)
}
} else {
// non-unique documents should always prompt for confirmation
setTenantSelection(option)
openModal(confirmSwitchTenantSlug)
}
},
[selectedTenantID, entityType, modified, switchTenant, openModal],
)
if (options.length <= 1) {
return null
}
return (
<div className="tenant-selector">
<SelectInput
isClearable={viewType === 'list'}
label={
label ? getTranslation(label, i18n) : t('plugin-multi-tenant:nav-tenantSelector-label')
}
name="setTenant"
onChange={onChange}
options={options}
path="setTenant"
value={selectedTenantID as string | undefined}
/>
<ConfirmationModal
body={
<Translation
elements={{
0: ({ children }) => {
return <b>{children}</b>
},
}}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
i18nKey="plugin-multi-tenant:confirm-modal-tenant-switch--body"
t={t}
variables={{
fromTenant: selectedValue?.label,
toTenant: newSelectedValue?.label,
}}
/>
}
heading={t('plugin-multi-tenant:confirm-modal-tenant-switch--heading', {
tenantLabel: label
? getTranslation(label, i18n)
: t('plugin-multi-tenant:nav-tenantSelector-label'),
})}
modalSlug={confirmSwitchTenantSlug}
onConfirm={() => {
switchTenant(tenantSelection)
}}
/>
<ConfirmationModal
body={t('general:changesNotSaved')}
cancelLabel={t('general:stayOnThisPage')}
confirmLabel={t('general:leaveAnyway')}
heading={t('general:leaveWithoutSaving')}
modalSlug={confirmLeaveWithoutSavingSlug}
onCancel={() => {
closeModal(confirmLeaveWithoutSavingSlug)
}}
onConfirm={() => {
switchTenant(tenantSelection)
}}
/>
</div>
)
return <TenantSelectorClient disabled={!enabled} label={label} viewType={viewType} />
}

View File

@@ -1,4 +1,3 @@
export { TenantField } from '../components/TenantField/index.client.js'
export { TenantSelector } from '../components/TenantSelector/index.js'
export { WatchTenantCollection } from '../components/WatchTenantCollection/index.js'
export { useTenantSelection } from '../providers/TenantSelectionProvider/index.client.js'

View File

@@ -1,2 +1,3 @@
export { GlobalViewRedirect } from '../components/GlobalViewRedirect/index.js'
export { TenantSelector } from '../components/TenantSelector/index.js'
export { TenantSelectionProvider } from '../providers/TenantSelectionProvider/index.js'

View File

@@ -1,17 +1,35 @@
import type { RelationshipFieldSingleValidation, SingleRelationshipField } from 'payload'
import { APIError } from 'payload'
import type { RootTenantFieldConfigOverrides } from '../../types.js'
import { defaults } from '../../defaults.js'
import { getCollectionIDType } from '../../utilities/getCollectionIDType.js'
import { getTenantFromCookie } from '../../utilities/getTenantFromCookie.js'
import { getUserTenantIDs } from '../../utilities/getUserTenantIDs.js'
const fieldValidation =
(validateFunction?: RelationshipFieldSingleValidation): RelationshipFieldSingleValidation =>
(value, options) => {
if (validateFunction) {
const result = validateFunction(value, options)
if (result !== true) {
return result
}
}
if (!value) {
return options.req.t('validation:required')
}
return true
}
type Args = {
debug?: boolean
name: string
overrides?: RootTenantFieldConfigOverrides
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
unique: boolean
}
@@ -19,6 +37,8 @@ export const tenantField = ({
name = defaults.tenantFieldName,
debug,
overrides: _overrides = {},
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
tenantsCollectionSlug = defaults.tenantCollectionSlug,
unique,
}: Args): SingleRelationshipField => {
@@ -27,23 +47,24 @@ export const tenantField = ({
...(overrides || {}),
name,
type: 'relationship',
access: overrides?.access || {},
access: overrides.access || {},
admin: {
allowCreate: false,
allowEdit: false,
disableListColumn: true,
disableListFilter: true,
...(overrides?.admin || {}),
position: 'sidebar',
...(overrides.admin || {}),
components: {
...(overrides?.admin?.components || {}),
...(overrides.admin?.components || {}),
Field: {
path: '@payloadcms/plugin-multi-tenant/client#TenantField',
...(typeof overrides?.admin?.components?.Field !== 'string'
? overrides?.admin?.components?.Field || {}
...(typeof overrides.admin?.components?.Field !== 'string'
? overrides.admin?.components?.Field || {}
: {}),
clientProps: {
...(typeof overrides?.admin?.components?.Field !== 'string'
? (overrides?.admin?.components?.Field || {})?.clientProps
...(typeof overrides.admin?.components?.Field !== 'string'
? (overrides.admin?.components?.Field || {})?.clientProps
: {}),
debug,
unique,
@@ -51,33 +72,55 @@ export const tenantField = ({
},
},
},
hasMany: false,
hooks: {
...(overrides.hooks || []),
beforeChange: [
({ req, value }) => {
const idType = getCollectionIDType({
collectionSlug: tenantsCollectionSlug,
payload: req.payload,
defaultValue:
overrides.defaultValue ||
(async ({ req }) => {
const idType = getCollectionIDType({
collectionSlug: tenantsCollectionSlug,
payload: req.payload,
})
const tenantFromCookie = getTenantFromCookie(req.headers, idType)
if (tenantFromCookie) {
const isValidTenant = await req.payload.count({
collection: tenantsCollectionSlug,
depth: 0,
overrideAccess: false,
req,
user: req.user,
where: {
id: {
equals: tenantFromCookie,
},
},
})
if (!value) {
const tenantFromCookie = getTenantFromCookie(req.headers, idType)
if (tenantFromCookie) {
return tenantFromCookie
}
throw new APIError('You must select a tenant', 400, null, true)
return isValidTenant ? tenantFromCookie : null
}
return null
}),
filterOptions:
overrides.filterOptions ||
(({ req }) => {
const userAssignedTenants = getUserTenantIDs(req.user, {
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
})
if (userAssignedTenants.length > 0) {
return {
id: {
in: userAssignedTenants,
},
}
}
return idType === 'number' ? parseFloat(value) : value
},
...(overrides?.hooks?.beforeChange || []),
],
},
return true
}),
hasMany: false,
index: true,
validate: (validate as RelationshipFieldSingleValidation) || undefined,
// @ts-expect-error translations are not typed for this plugin
label: overrides?.label || (({ t }) => t('plugin-multi-tenant:field-assignedTenant-label')),
relationTo: tenantsCollectionSlug,
unique,
// TODO: V4 - replace validation with required: true
validate: fieldValidation(validate as RelationshipFieldSingleValidation),
// @ts-expect-error translations are not typed for this plugin
label: overrides.label || (({ t }) => t('plugin-multi-tenant:field-assignedTenant-label')),
}
}

View File

@@ -1,8 +1,6 @@
import type { AcceptedLanguages } from '@payloadcms/translations'
import type { CollectionConfig, Config } from 'payload'
import { deepMergeSimple } from 'payload'
import type { PluginDefaultTranslationsObject } from './translations/types.js'
import type { MultiTenantPluginConfig } from './types.js'
@@ -143,6 +141,74 @@ export const multiTenantPlugin =
[[], []],
)
/**
* The folders collection is added AFTER the plugin is initialized
* so if they added the folder slug to the plugin collections,
* we can assume that they have folders enabled
*/
const foldersSlug = incomingConfig.folders
? incomingConfig.folders.slug || 'payload-folders'
: 'payload-folders'
if (collectionSlugs.includes(foldersSlug)) {
const overrides = pluginConfig.collections[foldersSlug]?.tenantFieldOverrides
? pluginConfig.collections[foldersSlug]?.tenantFieldOverrides
: pluginConfig.tenantField || {}
incomingConfig.folders = incomingConfig.folders || {}
incomingConfig.folders.collectionOverrides = incomingConfig.folders.collectionOverrides || []
incomingConfig.folders.collectionOverrides.push(({ collection }) => {
/**
* Add tenant field to enabled collections
*/
const folderTenantField = tenantField({
...(pluginConfig?.tenantField || {}),
name: tenantFieldName,
debug: pluginConfig.debug,
overrides,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
unique: false,
})
collection.fields.unshift(folderTenantField)
if (pluginConfig.collections[foldersSlug]?.useBaseListFilter !== false) {
/**
* Add list filter to enabled collections
* - filters results by selected tenant
*/
collection.admin = collection.admin || {}
collection.admin.baseFilter = combineFilters({
baseFilter: collection.admin?.baseFilter ?? collection.admin?.baseListFilter,
customFilter: (args) =>
filterDocumentsByTenants({
filterFieldName: tenantFieldName,
req: args.req,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
}),
})
}
if (pluginConfig.collections[foldersSlug]?.useTenantAccess !== false) {
/**
* Add access control constraint to tenant enabled folders collection
*/
addCollectionAccess({
adminUsersSlug: adminUsersCollection.slug,
collection,
fieldName: tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
})
}
return collection
})
}
/**
* Modify collections
*/
@@ -249,6 +315,8 @@ export const multiTenantPlugin =
tenantEnabledCollectionSlugs: collectionSlugs,
tenantEnabledGlobalSlugs: globalCollectionSlugs,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
})
@@ -266,6 +334,8 @@ export const multiTenantPlugin =
name: tenantFieldName,
debug: pluginConfig.debug,
overrides,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
unique: isGlobal,
}),
@@ -354,9 +424,15 @@ export const multiTenantPlugin =
*/
incomingConfig.admin.components.beforeNavLinks.push({
clientProps: {
enabledSlugs: [
...collectionSlugs,
...globalCollectionSlugs,
adminUsersCollection.slug,
tenantCollection.slug,
],
label: pluginConfig.tenantSelectorLabel || undefined,
},
path: '@payloadcms/plugin-multi-tenant/client#TenantSelector',
path: '@payloadcms/plugin-multi-tenant/rsc#TenantSelector',
})
/**

View File

@@ -6,6 +6,8 @@ import { toast, useAuth, useConfig } from '@payloadcms/ui'
import { useRouter } from 'next/navigation.js'
import React, { createContext } from 'react'
import { generateCookie } from '../../utilities/generateCookie.js'
type ContextType = {
/**
* What is the context of the selector? It is either 'document' | 'global' | undefined.
@@ -63,6 +65,26 @@ const Context = createContext<ContextType>({
updateTenants: () => null,
})
const setCookie = (value?: string) => {
document.cookie = generateCookie<string>({
name: 'payload-tenant',
maxAge: 60 * 60 * 24 * 365, // 1 year in seconds
path: '/',
returnCookieAsObject: false,
value: value || '',
})
}
const deleteCookie = () => {
document.cookie = generateCookie<string>({
name: 'payload-tenant',
maxAge: -1,
path: '/',
returnCookieAsObject: false,
value: '',
})
}
export const TenantSelectionProviderClient = ({
children,
initialTenantOptions,
@@ -81,6 +103,7 @@ export const TenantSelectionProviderClient = ({
const [entityType, setEntityType] = React.useState<'document' | 'global' | undefined>(undefined)
const { user } = useAuth()
const { config } = useConfig()
const router = useRouter()
const userID = React.useMemo(() => user?.id, [user?.id])
const prevUserID = React.useRef(userID)
const userChanged = userID !== prevUserID.current
@@ -92,47 +115,43 @@ export const TenantSelectionProviderClient = ({
[selectedTenantID, tenantOptions],
)
const router = useRouter()
const setCookie = React.useCallback((value?: string) => {
const expires = '; expires=Fri, 31 Dec 9999 23:59:59 GMT'
document.cookie = 'payload-tenant=' + (value || '') + expires + '; path=/'
}, [])
const deleteCookie = React.useCallback(() => {
// eslint-disable-next-line react-compiler/react-compiler -- TODO: fix
document.cookie = 'payload-tenant=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'
}, [])
const setTenantAndCookie = React.useCallback(
({ id, refresh }: { id: number | string | undefined; refresh?: boolean }) => {
setSelectedTenantID(id)
if (id !== undefined) {
setCookie(String(id))
} else {
deleteCookie()
}
if (refresh) {
router.refresh()
}
},
[router],
)
const setTenant = React.useCallback<ContextType['setTenant']>(
({ id, refresh }) => {
if (id === undefined) {
if (tenantOptions.length > 1) {
if (tenantOptions.length > 1 || tenantOptions.length === 0) {
// users with multiple tenants can clear the tenant selection
setSelectedTenantID(undefined)
deleteCookie()
setTenantAndCookie({ id: undefined, refresh })
} else if (tenantOptions[0]) {
// if there is only one tenant, force the selection of that tenant
setSelectedTenantID(tenantOptions[0].value)
setCookie(String(tenantOptions[0].value))
// if there is only one tenant, auto-select that tenant
setTenantAndCookie({ id: tenantOptions[0].value, refresh: true })
}
} else if (!tenantOptions.find((option) => option.value === id)) {
// if the tenant is not valid, set the first tenant as selected
if (tenantOptions[0]?.value) {
setTenant({ id: tenantOptions[0]?.value, refresh: true })
} else {
setTenant({ id: undefined, refresh: true })
}
// if the tenant is invalid, set the first tenant as selected
setTenantAndCookie({
id: tenantOptions[0]?.value,
refresh,
})
} else {
// if the tenant is in the options, set it as selected
setSelectedTenantID(id)
setCookie(String(id))
}
if (entityType !== 'document' && refresh) {
router.refresh()
setTenantAndCookie({ id, refresh })
}
},
[deleteCookie, entityType, router, setCookie, tenantOptions],
[tenantOptions, setTenantAndCookie],
)
const syncTenants = React.useCallback(async () => {
@@ -158,7 +177,7 @@ export const TenantSelectionProviderClient = ({
} catch (e) {
toast.error(`Error fetching tenants`)
}
}, [config.serverURL, config.routes.api, tenantsCollectionSlug, setCookie, userID])
}, [config.serverURL, config.routes.api, tenantsCollectionSlug, userID])
const updateTenants = React.useCallback<ContextType['updateTenants']>(
({ id, label }) => {
@@ -194,7 +213,30 @@ export const TenantSelectionProviderClient = ({
}
prevUserID.current = userID
}
}, [userID, userChanged, syncTenants, deleteCookie, tenantOptions])
}, [userID, userChanged, syncTenants, tenantOptions])
/**
* If there is no initial value, clear the tenant and refresh the router.
* Needed for stale tenantIDs set as a cookie.
*/
React.useEffect(() => {
if (!initialValue) {
setTenant({ id: undefined, refresh: true })
}
}, [initialValue, setTenant])
/**
* If there is no selected tenant ID and the entity type is 'global', set the first tenant as selected.
* This ensures that the global tenant is always set when the component mounts.
*/
React.useEffect(() => {
if (!selectedTenantID && tenantOptions.length > 0 && entityType === 'global') {
setTenant({
id: tenantOptions[0]?.value,
refresh: true,
})
}
}, [selectedTenantID, tenantOptions, entityType, setTenant])
return (
<span

View File

@@ -3,7 +3,7 @@ import type { PluginDefaultTranslationsObject, PluginLanguage } from '../types.j
export const zhTwTranslations: PluginDefaultTranslationsObject = {
'plugin-multi-tenant': {
'confirm-modal-tenant-switch--body':
'您即將<0>{{fromTenant}}</0>變更所有權至<0>{{toTenant}}</0>',
'您即將變更擁有者,從 <0>{{fromTenant}}</0> 切換為 <0>{{toTenant}}</0>',
'confirm-modal-tenant-switch--heading': '確認變更 {{tenantLabel}}',
'field-assignedTenant-label': '指派的租用戶',
'nav-tenantSelector-label': '租戶',

View File

@@ -49,7 +49,6 @@ export const addCollectionAccess = <ConfigType>({
adminUsersSlug,
collection,
fieldName: key === 'readVersions' ? `version.${fieldName}` : fieldName,
operation: key,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,

View File

@@ -1,7 +1,7 @@
import type { Config, Field, FilterOptionsProps, RelationshipField, SanitizedConfig } from 'payload'
import type { Config, Field, RelationshipField, SanitizedConfig } from 'payload'
import { getCollectionIDType } from './getCollectionIDType.js'
import { getTenantFromCookie } from './getTenantFromCookie.js'
import { defaults } from '../defaults.js'
import { filterDocumentsByTenants } from '../filters/filterDocumentsByTenants.js'
type AddFilterOptionsToFieldsArgs = {
config: Config | SanitizedConfig
@@ -9,6 +9,8 @@ type AddFilterOptionsToFieldsArgs = {
tenantEnabledCollectionSlugs: string[]
tenantEnabledGlobalSlugs: string[]
tenantFieldName: string
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
}
@@ -18,6 +20,8 @@ export function addFilterOptionsToFields({
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
tenantFieldName,
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
tenantsCollectionSlug,
}: AddFilterOptionsToFieldsArgs) {
fields.forEach((field) => {
@@ -33,7 +37,14 @@ export function addFilterOptionsToFields({
)
}
if (tenantEnabledCollectionSlugs.includes(field.relationTo)) {
addFilter({ field, tenantEnabledCollectionSlugs, tenantFieldName, tenantsCollectionSlug })
addFilter({
field,
tenantEnabledCollectionSlugs,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
})
}
} else {
field.relationTo.map((relationTo) => {
@@ -47,6 +58,8 @@ export function addFilterOptionsToFields({
field,
tenantEnabledCollectionSlugs,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
})
}
@@ -66,6 +79,8 @@ export function addFilterOptionsToFields({
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
})
}
@@ -85,6 +100,8 @@ export function addFilterOptionsToFields({
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
})
}
@@ -99,6 +116,8 @@ export function addFilterOptionsToFields({
tenantEnabledCollectionSlugs,
tenantEnabledGlobalSlugs,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
})
})
@@ -110,12 +129,16 @@ type AddFilterArgs = {
field: RelationshipField
tenantEnabledCollectionSlugs: string[]
tenantFieldName: string
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
}
function addFilter({
field,
tenantEnabledCollectionSlugs,
tenantFieldName,
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayTenantFieldName,
tenantsCollectionSlug,
}: AddFilterArgs) {
// User specified filter
@@ -135,14 +158,16 @@ function addFilter({
}
// Custom tenant filter
const tenantFilterResults = filterOptionsByTenant({
...args,
tenantFieldName,
const tenantFilterResults = filterDocumentsByTenants({
filterFieldName: tenantFieldName,
req: args.req,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
})
// If the tenant filter returns true, just use the original filter
if (tenantFilterResults === true) {
// If the tenant filter returns null, meaning no tenant filter, just use the original filter
if (tenantFilterResults === null) {
return originalFilterResult
}
@@ -156,39 +181,3 @@ function addFilter({
}
}
}
type Args = {
tenantFieldName?: string
tenantsCollectionSlug: string
} & FilterOptionsProps
const filterOptionsByTenant = ({
req,
tenantFieldName = 'tenant',
tenantsCollectionSlug,
}: Args) => {
const idType = getCollectionIDType({
collectionSlug: tenantsCollectionSlug,
payload: req.payload,
})
const selectedTenant = getTenantFromCookie(req.headers, idType)
if (!selectedTenant) {
return true
}
return {
or: [
// ie a related collection that doesn't have a tenant field
{
[tenantFieldName]: {
exists: false,
},
},
// related collections that have a tenant field
{
[tenantFieldName]: {
equals: selectedTenant,
},
},
],
}
}

View File

@@ -0,0 +1,105 @@
type CookieOptions = {
domain?: string
expires?: Date
httpOnly?: boolean
maxAge?: number
name: string
path?: string
returnCookieAsObject: boolean
sameSite?: 'Lax' | 'None' | 'Strict'
secure?: boolean
value?: string
}
type CookieObject = {
domain?: string
expires?: string
httpOnly?: boolean
maxAge?: number
name: string
path?: string
sameSite?: 'Lax' | 'None' | 'Strict'
secure?: boolean
value: string | undefined
}
export const generateCookie = <ReturnCookieAsObject = boolean>(
args: CookieOptions,
): ReturnCookieAsObject extends true ? CookieObject : string => {
const {
name,
domain,
expires,
httpOnly,
maxAge,
path,
returnCookieAsObject,
sameSite,
secure: secureArg,
value,
} = args
let cookieString = `${name}=${value || ''}`
const cookieObject: CookieObject = {
name,
value,
}
const secure = secureArg || sameSite === 'None'
if (expires) {
if (returnCookieAsObject) {
cookieObject.expires = expires.toUTCString()
} else {
cookieString += `; Expires=${expires.toUTCString()}`
}
}
if (maxAge) {
if (returnCookieAsObject) {
cookieObject.maxAge = maxAge
} else {
cookieString += `; Max-Age=${maxAge.toString()}`
}
}
if (domain) {
if (returnCookieAsObject) {
cookieObject.domain = domain
} else {
cookieString += `; Domain=${domain}`
}
}
if (path) {
if (returnCookieAsObject) {
cookieObject.path = path
} else {
cookieString += `; Path=${path}`
}
}
if (secure) {
if (returnCookieAsObject) {
cookieObject.secure = secure
} else {
cookieString += `; Secure`
}
}
if (httpOnly) {
if (returnCookieAsObject) {
cookieObject.httpOnly = httpOnly
} else {
cookieString += `; HttpOnly`
}
}
if (sameSite) {
if (returnCookieAsObject) {
cookieObject.sameSite = sameSite
} else {
cookieString += `; SameSite=${sameSite}`
}
}
return returnCookieAsObject ? (cookieObject as any) : (cookieString as any)
}

View File

@@ -1,12 +1,4 @@
import type {
Access,
AccessArgs,
AccessResult,
AllOperations,
CollectionConfig,
TypedUser,
Where,
} from 'payload'
import type { Access, AccessArgs, AccessResult, CollectionConfig, TypedUser, Where } from 'payload'
import type { MultiTenantPluginConfig, UserWithTenantsField } from '../types.js'
@@ -18,7 +10,6 @@ type Args<ConfigType> = {
adminUsersSlug: string
collection: CollectionConfig
fieldName: string
operation: AllOperations
tenantsArrayFieldName?: string
tenantsArrayTenantFieldName?: string
userHasAccessToAllTenants: Required<

View File

@@ -195,16 +195,13 @@ export function AuthProvider({
if (user && user.collection) {
await requests.post(`${serverURL}${apiRoute}/${user.collection}/logout`)
}
return true
} catch (e) {
toast.error(`Logging out failed: ${e.message}`)
return false
} finally {
// Always clear local auth state
setNewUser(null)
revokeTokenAndExpire()
} catch (_) {
// fail silently and log the user out in state
}
}, [apiRoute, revokeTokenAndExpire, serverURL, setNewUser, user])
setNewUser(null)
return true
}, [apiRoute, serverURL, setNewUser, user])
const refreshPermissions = useCallback(
async ({ locale }: { locale?: string } = {}) => {

16
pnpm-lock.yaml generated
View File

@@ -19487,8 +19487,8 @@ snapshots:
'@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3)
eslint: 9.22.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@1.21.6))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.22.0(jiti@1.21.6))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.22.0(jiti@1.21.6))
eslint-plugin-react: 7.37.5(eslint@9.22.0(jiti@1.21.6))
eslint-plugin-react-hooks: 5.2.0(eslint@9.22.0(jiti@1.21.6))
@@ -19511,7 +19511,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@1.21.6)):
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.1
@@ -19522,19 +19522,19 @@ snapshots:
tinyglobby: 0.2.14
unrs-resolver: 1.9.0
optionalDependencies:
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.22.0(jiti@1.21.6))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6))
eslint-plugin-import-x: 4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3)
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)):
eslint-module-utils@2.12.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3)
eslint: 9.22.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@1.21.6))
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6))
transitivePeerDependencies:
- supports-color
@@ -19558,7 +19558,7 @@ snapshots:
- supports-color
- typescript
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.22.0(jiti@1.21.6)):
eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.8
@@ -19569,7 +19569,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.22.0(jiti@1.21.6)
eslint-import-resolver-node: 0.3.9
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0)(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6))
eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.6.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.26.1(eslint@9.22.0(jiti@1.21.6))(typescript@5.7.3))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6)))(eslint@9.22.0(jiti@1.21.6))
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3

View File

@@ -204,6 +204,11 @@ export async function loginClientSide(args: LoginArgs): Promise<void> {
await openNav(page)
await expect(page.locator('.nav__controls [aria-label="Log out"]')).toBeVisible()
await page.locator('.nav__controls [aria-label="Log out"]').click()
if (await page.locator('dialog#leave-without-saving').isVisible()) {
await page.locator('dialog#leave-without-saving #confirm-action').click()
}
await page.waitForURL(loginRoute)
}

View File

@@ -43,7 +43,7 @@ const collectionTenantReadAccess: Access = ({ req }) => {
} as Where
}
const collectionTenantUpdateAccess: Access = ({ req }) => {
const collectionTenantUpdateAndDeleteAccess: Access = ({ req }) => {
// admins can update all tenants
if (req?.user?.roles?.includes('admin')) {
return true
@@ -73,12 +73,10 @@ export const MenuItems: CollectionConfig = {
access: {
read: collectionTenantReadAccess,
create: ({ req }) => {
return Boolean(req?.user?.roles?.includes('admin'))
},
update: collectionTenantUpdateAccess,
delete: ({ req }) => {
return Boolean(req?.user?.roles?.includes('admin'))
return Boolean(req?.user?.roles?.includes('admin') || req.user?.tenants?.length)
},
update: collectionTenantUpdateAndDeleteAccess,
delete: collectionTenantUpdateAndDeleteAccess,
},
admin: {
useAsTitle: 'name',

View File

@@ -31,6 +31,7 @@ export default buildConfigWithDefaults({
onInit: seed,
plugins: [
multiTenantPlugin<ConfigType>({
debug: true,
userHasAccessToAllTenants: (user) => Boolean(user.roles?.includes('admin')),
useTenantsCollectionAccess: false,
tenantField: {

View File

@@ -1,4 +1,5 @@
import type { Page } from '@playwright/test'
import type { BasePayload } from 'payload'
import { expect, test } from '@playwright/test'
import * as path from 'path'
@@ -26,6 +27,7 @@ import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { credentials } from './credentials.js'
import { seed } from './seed/index.js'
import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
@@ -42,7 +44,7 @@ test.describe('Multi Tenant', () => {
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
const { serverURL: serverFromInit } = await initPayloadE2ENoConfig<Config>({ dirname })
const { serverURL: serverFromInit, payload } = await initPayloadE2ENoConfig<Config>({ dirname })
serverURL = serverFromInit
globalMenuURL = new AdminUrlUtil(serverURL, menuSlug)
menuItemsURL = new AdminUrlUtil(serverURL, menuItemsSlug)
@@ -53,13 +55,14 @@ test.describe('Multi Tenant', () => {
page = await context.newPage()
initPageConsoleErrorCatch(page)
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
})
test.beforeEach(async () => {
await reInitializeDB({
serverURL,
snapshotKey: 'multiTenant',
})
if (seed) {
await seed(payload as unknown as BasePayload)
await ensureCompilationIsDone({ page, serverURL, noAutoLogin: true })
}
})
test.describe('Filters', () => {
@@ -74,6 +77,7 @@ test.describe('Multi Tenant', () => {
await clearTenant({ page })
await page.goto(tenantsURL.list)
await expect(
page.locator('.collection-list .table .cell-name', {
hasText: 'Blue Dog',
@@ -97,12 +101,12 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await selectTenant({
page,
tenant: 'Blue Dog',
})
await page.goto(tenantsURL.list)
await expect(
page.locator('.collection-list .table .cell-name', {
hasText: 'Blue Dog',
@@ -124,9 +128,9 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await page.goto(menuItemsURL.list)
await expect(
page.locator('.collection-list .table .cell-name', {
hasText: 'Spicy Mac',
@@ -145,12 +149,12 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(menuItemsURL.list)
await selectTenant({
page,
tenant: 'Blue Dog',
})
await page.goto(menuItemsURL.list)
await expect(
page.locator('.collection-list .table .cell-name', {
hasText: 'Spicy Mac',
@@ -169,9 +173,9 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await page.goto(menuItemsURL.list)
await expect(
page.locator('.collection-list .table .cell-name', {
hasText: 'Free Pizza',
@@ -185,9 +189,9 @@ test.describe('Multi Tenant', () => {
data: credentials.owner,
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await page.goto(menuItemsURL.list)
await expect(
page.locator('.collection-list .table .cell-name', {
hasText: 'Free Pizza',
@@ -204,9 +208,9 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(usersURL.list)
await clearTenant({ page })
await page.goto(usersURL.list)
await expect(
page.locator('.collection-list .table .cell-email', {
hasText: 'jane@blue-dog.com',
@@ -231,12 +235,12 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(usersURL.list)
await selectTenant({
page,
tenant: 'Blue Dog',
})
await page.goto(usersURL.list)
await expect(
page.locator('.collection-list .table .cell-email', {
hasText: 'jane@blue-dog.com',
@@ -264,6 +268,7 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await goToListDoc({
@@ -291,6 +296,7 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(menuItemsURL.list)
await clearTenant({ page })
await goToListDoc({
@@ -300,7 +306,7 @@ test.describe('Multi Tenant', () => {
urlUtil: menuItemsURL,
})
await selectTenant({
await selecteDocumentTenant({
page,
tenant: 'Steel Cat',
})
@@ -317,11 +323,11 @@ test.describe('Multi Tenant', () => {
serverURL,
data: credentials.admin,
})
await selectTenant({
await page.goto(menuItemsURL.create)
await selecteDocumentTenant({
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')
@@ -331,7 +337,12 @@ test.describe('Multi Tenant', () => {
}
await page.keyboard.up('Shift')
await page.locator('.toolbar-popup__button-link').click()
await page.locator('.radio-input__styled-radio').last().click()
await expect(page.locator('.lexical-link-edit-drawer')).toBeVisible()
const linkRadio = page.locator('.radio-input__styled-radio').last()
await expect(linkRadio).toBeVisible()
await linkRadio.click({
delay: 100,
})
await page.locator('.drawer__content').locator('.rs__input').click()
await expect(page.getByText('Chorizo Con Queso')).toBeVisible()
await expect(page.getByText('Pretzel Bites')).toBeHidden()
@@ -355,6 +366,7 @@ test.describe('Multi Tenant', () => {
serverURL,
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await selectTenant({
page,
tenant: 'Blue Dog',
@@ -371,6 +383,7 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await selectTenant({
page,
tenant: 'Blue Dog',
@@ -415,6 +428,8 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await expect
.poll(async () => {
return (await getTenantOptions({ page })).sort()
@@ -435,6 +450,8 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await expect
.poll(async () => {
return (await getTenantOptions({ page })).sort()
@@ -449,6 +466,8 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await expect
.poll(async () => {
return (await getTenantOptions({ page })).sort()
@@ -463,6 +482,8 @@ test.describe('Multi Tenant', () => {
data: credentials.owner,
})
await page.goto(tenantsURL.list)
await expect
.poll(async () => {
return (await getTenantOptions({ page })).sort()
@@ -477,9 +498,9 @@ test.describe('Multi Tenant', () => {
data: credentials.owner,
})
await page.goto(tenantsURL.list)
await clearTenant({ page })
await page.goto(tenantsURL.list)
await expect(
page.locator('.collection-list .table .cell-name', {
hasText: 'Public Tenant',
@@ -505,6 +526,8 @@ test.describe('Multi Tenant', () => {
await page.locator('#field-name').fill('Red Dog')
await saveDocAndAssert(page)
await page.goto(tenantsURL.list)
// Check the tenant selector
await expect
.poll(async () => {
@@ -512,9 +535,19 @@ test.describe('Multi Tenant', () => {
})
.toEqual(['Red Dog', 'Steel Cat', 'Public Tenant', 'Anchor Bar'].sort())
await goToListDoc({
cellClass: '.cell-name',
page,
textToMatch: 'Red Dog',
urlUtil: tenantsURL,
})
// Change the tenant back to the original name
await page.locator('#field-name').fill('Blue Dog')
await saveDocAndAssert(page)
await page.goto(tenantsURL.list)
await expect
.poll(async () => {
return (await getTenantOptions({ page })).sort()
@@ -538,6 +571,8 @@ test.describe('Multi Tenant', () => {
await page.locator('#field-domain').fill('house-rules.com')
await saveDocAndAssert(page)
await page.goto(tenantsURL.list)
// Check the tenant selector
await expect
.poll(async () => {
@@ -558,6 +593,21 @@ async function getTenantOptions({ page }: { page: Page }): Promise<string[]> {
})
}
async function selecteDocumentTenant({
page,
tenant,
}: {
page: Page
tenant: string
}): Promise<void> {
await openNav(page)
return selectInput({
selectLocator: page.locator('.tenantField'),
option: tenant,
multiSelect: false,
})
}
async function selectTenant({ page, tenant }: { page: Page; tenant: string }): Promise<void> {
await openNav(page)
return selectInput({

View File

@@ -175,7 +175,7 @@ export interface User {
*/
export interface FoodItem {
id: string;
tenant?: (string | null) | Tenant;
tenant: string | Tenant;
name: string;
content?: {
root: {
@@ -201,7 +201,7 @@ export interface FoodItem {
*/
export interface FoodMenu {
id: string;
tenant?: (string | null) | Tenant;
tenant: string | Tenant;
title: string;
description?: string | null;
menuItems?:

View File

@@ -1,9 +1,30 @@
import type { Config } from 'payload'
import type { Config, Payload } from 'payload'
import { credentials } from '../credentials.js'
import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from '../shared.js'
const deleteAll = async (payload: Payload) => {
await payload.delete({
collection: tenantsSlug,
where: {},
})
await payload.delete({
collection: usersSlug,
where: {},
})
await payload.delete({
collection: menuItemsSlug,
where: {},
})
await payload.delete({
collection: menuSlug,
where: {},
})
}
export const seed: Config['onInit'] = async (payload) => {
await deleteAll(payload)
// create tenants
const blueDogTenant = await payload.create({
collection: tenantsSlug,