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:
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user