fix(plugin-multi-tenant): prefer assigned tenants for selector population (#13213)

When populating the selector it should populate it with assigned tenants
before fetching all tenants that a user has access to.

You may have "public" tenants and while a user may have _access_ to the
tenant, the selector should show the ones they are assigned to. Users
with full access are the ones that should be able to see the public ones
for editing.
This commit is contained in:
Jarrod Flesch
2025-07-25 10:10:26 -04:00
committed by GitHub
parent 4ac428d250
commit e29d1d98d4
8 changed files with 211 additions and 79 deletions

View File

@@ -3,6 +3,8 @@ import type { CollectionSlug, ServerProps, ViewTypes } from 'payload'
import { headers as getHeaders } from 'next/headers.js'
import { redirect } from 'next/navigation.js'
import type { MultiTenantPluginConfig } from '../../types.js'
import { getGlobalViewRedirect } from '../../utilities/getGlobalViewRedirect.js'
type Args = {
@@ -10,9 +12,12 @@ type Args = {
collectionSlug: CollectionSlug
docID?: number | string
globalSlugs: string[]
tenantArrayFieldName: string
tenantArrayTenantFieldName: string
tenantFieldName: string
tenantsCollectionSlug: string
useAsTitle: string
userHasAccessToAllTenants: Required<MultiTenantPluginConfig<any>>['userHasAccessToAllTenants']
viewType: ViewTypes
} & ServerProps
@@ -27,9 +32,12 @@ export const GlobalViewRedirect = async (args: Args) => {
headers,
payload: args.payload,
tenantFieldName: args.tenantFieldName,
tenantsArrayFieldName: args.tenantArrayFieldName,
tenantsArrayTenantFieldName: args.tenantArrayTenantFieldName,
tenantsCollectionSlug: args.tenantsCollectionSlug,
useAsTitle: args.useAsTitle,
user: args.user,
userHasAccessToAllTenants: args.userHasAccessToAllTenants,
view: args.viewType,
})

View File

@@ -0,0 +1,45 @@
import type { Endpoint } from 'payload'
import { APIError } from 'payload'
import type { MultiTenantPluginConfig } from '../types.js'
import { getTenantOptions } from '../utilities/getTenantOptions.js'
export const getTenantOptionsEndpoint = <ConfigType>({
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle,
userHasAccessToAllTenants,
}: {
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
useAsTitle: string
userHasAccessToAllTenants: Required<
MultiTenantPluginConfig<ConfigType>
>['userHasAccessToAllTenants']
}): Endpoint => ({
handler: async (req) => {
const { payload, user } = req
if (!user) {
throw new APIError('Unauthorized', 401)
}
const tenantOptions = await getTenantOptions({
payload,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle,
user,
userHasAccessToAllTenants,
})
return new Response(JSON.stringify({ tenantOptions }))
},
method: 'get',
path: '/populate-tenant-options',
})

View File

@@ -7,6 +7,7 @@ import type { PluginDefaultTranslationsObject } from './translations/types.js'
import type { MultiTenantPluginConfig } from './types.js'
import { defaults } from './defaults.js'
import { getTenantOptionsEndpoint } from './endpoints/getTenantOptionsEndpoint.js'
import { tenantField } from './fields/tenantField/index.js'
import { tenantsArrayField } from './fields/tenantsArrayField/index.js'
import { addTenantCleanup } from './hooks/afterTenantDelete.js'
@@ -248,6 +249,17 @@ export const multiTenantPlugin =
},
},
})
collection.endpoints = [
...(collection.endpoints || []),
getTenantOptionsEndpoint<ConfigType>({
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle: tenantCollection.admin?.useAsTitle || 'id',
userHasAccessToAllTenants,
}),
]
} else if (pluginConfig.collections?.[collection.slug]) {
const isGlobal = Boolean(pluginConfig.collections[collection.slug]?.isGlobal)
@@ -327,8 +339,11 @@ export const multiTenantPlugin =
*/
incomingConfig.admin.components.providers.push({
clientProps: {
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug: tenantCollection.slug,
useAsTitle: tenantCollection.admin?.useAsTitle || 'id',
userHasAccessToAllTenants,
},
path: '@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider',
})
@@ -343,8 +358,11 @@ export const multiTenantPlugin =
basePath,
globalSlugs: globalCollectionSlugs,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle: tenantCollection.admin?.useAsTitle || 'id',
userHasAccessToAllTenants,
},
})
}

View File

@@ -65,18 +65,14 @@ const Context = createContext<ContextType>({
export const TenantSelectionProviderClient = ({
children,
initialTenantOptions,
initialValue,
tenantCookie,
tenantOptions: tenantOptionsFromProps,
tenantsCollectionSlug,
useAsTitle,
}: {
children: React.ReactNode
initialTenantOptions: OptionObject[]
initialValue?: number | string
tenantCookie?: string
tenantOptions: OptionObject[]
tenantsCollectionSlug: string
useAsTitle: string
}) => {
const [selectedTenantID, setSelectedTenantID] = React.useState<number | string | undefined>(
initialValue,
@@ -89,7 +85,7 @@ export const TenantSelectionProviderClient = ({
const prevUserID = React.useRef(userID)
const userChanged = userID !== prevUserID.current
const [tenantOptions, setTenantOptions] = React.useState<OptionObject[]>(
() => tenantOptionsFromProps,
() => initialTenantOptions,
)
const selectedTenantLabel = React.useMemo(
() => tenantOptions.find((option) => option.value === selectedTenantID)?.label,
@@ -142,7 +138,7 @@ export const TenantSelectionProviderClient = ({
const syncTenants = React.useCallback(async () => {
try {
const req = await fetch(
`${config.serverURL}${config.routes.api}/${tenantsCollectionSlug}?select[${useAsTitle}]=true&limit=0&depth=0`,
`${config.serverURL}${config.routes.api}/${tenantsCollectionSlug}/populate-tenant-options`,
{
credentials: 'include',
method: 'GET',
@@ -151,23 +147,18 @@ export const TenantSelectionProviderClient = ({
const result = await req.json()
if (result.docs && userID) {
setTenantOptions(
result.docs.map((doc: Record<string, number | string>) => ({
label: doc[useAsTitle],
value: doc.id,
})),
)
if (result.tenantOptions && userID) {
setTenantOptions(result.tenantOptions)
if (result.totalDocs === 1) {
setSelectedTenantID(result.docs[0].id)
setCookie(String(result.docs[0].id))
if (result.tenantOptions.length === 1) {
setSelectedTenantID(result.tenantOptions[0].value)
setCookie(String(result.tenantOptions[0].value))
}
}
} catch (e) {
toast.error(`Error fetching tenants`)
}
}, [config.serverURL, config.routes.api, tenantsCollectionSlug, useAsTitle, setCookie, userID])
}, [config.serverURL, config.routes.api, tenantsCollectionSlug, setCookie, userID])
const updateTenants = React.useCallback<ContextType['updateTenants']>(
({ id, label }) => {

View File

@@ -1,45 +1,47 @@
import type { OptionObject, Payload, TypedUser } from 'payload'
import type { Payload, TypedUser } from 'payload'
import { cookies as getCookies } from 'next/headers.js'
import { findTenantOptions } from '../../queries/findTenantOptions.js'
import type { MultiTenantPluginConfig } from '../../types.js'
import { getTenantOptions } from '../../utilities/getTenantOptions.js'
import { TenantSelectionProviderClient } from './index.client.js'
type Args = {
type Args<ConfigType> = {
children: React.ReactNode
payload: Payload
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
useAsTitle: string
user: TypedUser
userHasAccessToAllTenants: Required<
MultiTenantPluginConfig<ConfigType>
>['userHasAccessToAllTenants']
}
export const TenantSelectionProvider = async ({
children,
payload,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle,
user,
}: Args) => {
let tenantOptions: OptionObject[] = []
try {
const { docs } = await findTenantOptions({
limit: 0,
payload,
tenantsCollectionSlug,
useAsTitle,
user,
})
tenantOptions = docs.map((doc) => ({
label: String(doc[useAsTitle]),
value: doc.id,
}))
} catch (_) {
// user likely does not have access
}
userHasAccessToAllTenants,
}: Args<any>) => {
const tenantOptions = await getTenantOptions({
payload,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle,
user,
userHasAccessToAllTenants,
})
const cookies = await getCookies()
let tenantCookie = cookies.get('payload-tenant')?.value
const tenantCookie = cookies.get('payload-tenant')?.value
let initialValue = undefined
/**
@@ -56,17 +58,14 @@ export const TenantSelectionProvider = async ({
* If the there was no cookie or the cookie was an invalid tenantID set intialValue
*/
if (!initialValue) {
tenantCookie = undefined
initialValue = tenantOptions.length > 1 ? undefined : tenantOptions[0]?.value
}
return (
<TenantSelectionProviderClient
initialTenantOptions={tenantOptions}
initialValue={initialValue}
tenantCookie={tenantCookie}
tenantOptions={tenantOptions}
tenantsCollectionSlug={tenantsCollectionSlug}
useAsTitle={useAsTitle}
>
{children}
</TenantSelectionProviderClient>

View File

@@ -1,30 +0,0 @@
import type { PaginatedDocs, Payload, TypedUser } from 'payload'
type Args = {
limit: number
payload: Payload
tenantsCollectionSlug: string
useAsTitle: string
user?: TypedUser
}
export const findTenantOptions = async ({
limit,
payload,
tenantsCollectionSlug,
useAsTitle,
user,
}: Args): Promise<PaginatedDocs> => {
const isOrderable = payload.collections[tenantsCollectionSlug]?.config?.orderable || false
return payload.find({
collection: tenantsCollectionSlug,
depth: 0,
limit,
overrideAccess: false,
select: {
[useAsTitle]: true,
...(isOrderable ? { _order: true } : {}),
},
sort: isOrderable ? '_order' : useAsTitle,
user,
})
}

View File

@@ -1,10 +1,13 @@
import type { Payload, TypedUser, ViewTypes } from 'payload'
import { unauthorized } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import { findTenantOptions } from '../queries/findTenantOptions.js'
import type { MultiTenantPluginConfig } from '../types.js'
import { getCollectionIDType } from './getCollectionIDType.js'
import { getTenantFromCookie } from './getTenantFromCookie.js'
import { getTenantOptions } from './getTenantOptions.js'
type Args = {
basePath?: string
@@ -13,9 +16,12 @@ type Args = {
payload: Payload
slug: string
tenantFieldName: string
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
useAsTitle: string
user?: TypedUser
userHasAccessToAllTenants: Required<MultiTenantPluginConfig<any>>['userHasAccessToAllTenants']
view: ViewTypes
}
export async function getGlobalViewRedirect({
@@ -25,9 +31,12 @@ export async function getGlobalViewRedirect({
headers,
payload,
tenantFieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle,
user,
userHasAccessToAllTenants,
view,
}: Args): Promise<string | void> {
const idType = getCollectionIDType({
@@ -37,16 +46,22 @@ export async function getGlobalViewRedirect({
let tenant = getTenantFromCookie(headers, idType)
let redirectRoute: `/${string}` | void = undefined
if (!user) {
return unauthorized()
}
if (!tenant) {
const tenantsQuery = await findTenantOptions({
limit: 1,
const tenantOptions = await getTenantOptions({
payload,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle,
user,
userHasAccessToAllTenants,
})
tenant = tenantsQuery.docs[0]?.id || null
tenant = tenantOptions[0]?.value || null
}
try {

View File

@@ -0,0 +1,86 @@
import type { OptionObject, Payload, TypedUser } from 'payload'
import type { MultiTenantPluginConfig } from '../types.js'
export const getTenantOptions = async ({
payload,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
tenantsCollectionSlug,
useAsTitle,
user,
userHasAccessToAllTenants,
}: {
payload: Payload
tenantsArrayFieldName: string
tenantsArrayTenantFieldName: string
tenantsCollectionSlug: string
useAsTitle: string
user: TypedUser
userHasAccessToAllTenants: Required<MultiTenantPluginConfig<any>>['userHasAccessToAllTenants']
}): Promise<OptionObject[]> => {
let tenantOptions: OptionObject[] = []
if (!user) {
return tenantOptions
}
if (userHasAccessToAllTenants(user)) {
// If the user has access to all tenants get them from the DB
const isOrderable = payload.collections[tenantsCollectionSlug]?.config?.orderable || false
const tenants = await payload.find({
collection: tenantsCollectionSlug,
depth: 0,
limit: 0,
overrideAccess: false,
select: {
[useAsTitle]: true,
...(isOrderable ? { _order: true } : {}),
},
sort: isOrderable ? '_order' : useAsTitle,
user,
})
tenantOptions = tenants.docs.map((doc) => ({
label: String(doc[useAsTitle as 'id']), // useAsTitle is dynamic but the type thinks we are only selecting `id` | `_order`
value: doc.id as string,
}))
} else {
const tenantsToPopulate: (number | string)[] = []
// i.e. users.tenants
;((user[tenantsArrayFieldName] as { [key: string]: any }[]) || []).map((tenantRow) => {
const tenantField = tenantRow[tenantsArrayTenantFieldName] // tenants.tenant
if (typeof tenantField === 'string' || typeof tenantField === 'number') {
tenantsToPopulate.push(tenantField)
} else if (tenantField && typeof tenantField === 'object') {
tenantOptions.push({
label: String(tenantField[useAsTitle]),
value: tenantField.id,
})
}
})
if (tenantsToPopulate.length > 0) {
const populatedTenants = await payload.find({
collection: tenantsCollectionSlug,
depth: 0,
limit: 0,
overrideAccess: false,
user,
where: {
id: {
in: tenantsToPopulate,
},
},
})
tenantOptions = populatedTenants.docs.map((doc) => ({
label: String(doc[useAsTitle]),
value: doc.id as string,
}))
}
}
return tenantOptions
}