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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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': '租戶',
|
||||
|
||||
@@ -49,7 +49,6 @@ export const addCollectionAccess = <ConfigType>({
|
||||
adminUsersSlug,
|
||||
collection,
|
||||
fieldName: key === 'readVersions' ? `version.${fieldName}` : fieldName,
|
||||
operation: key,
|
||||
tenantsArrayFieldName,
|
||||
tenantsArrayTenantFieldName,
|
||||
userHasAccessToAllTenants,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
105
packages/plugin-multi-tenant/src/utilities/generateCookie.ts
Normal file
105
packages/plugin-multi-tenant/src/utilities/generateCookie.ts
Normal 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)
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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
16
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -31,6 +31,7 @@ export default buildConfigWithDefaults({
|
||||
onInit: seed,
|
||||
plugins: [
|
||||
multiTenantPlugin<ConfigType>({
|
||||
debug: true,
|
||||
userHasAccessToAllTenants: (user) => Boolean(user.roles?.includes('admin')),
|
||||
useTenantsCollectionAccess: false,
|
||||
tenantField: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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?:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user