diff --git a/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts b/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts index 9f99f41f2d1..702b43a9712 100644 --- a/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts +++ b/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts @@ -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>['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, }) diff --git a/packages/plugin-multi-tenant/src/endpoints/getTenantOptionsEndpoint.ts b/packages/plugin-multi-tenant/src/endpoints/getTenantOptionsEndpoint.ts new file mode 100644 index 00000000000..fe289f61304 --- /dev/null +++ b/packages/plugin-multi-tenant/src/endpoints/getTenantOptionsEndpoint.ts @@ -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 = ({ + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + tenantsCollectionSlug, + useAsTitle, + userHasAccessToAllTenants, +}: { + tenantsArrayFieldName: string + tenantsArrayTenantFieldName: string + tenantsCollectionSlug: string + useAsTitle: string + userHasAccessToAllTenants: Required< + MultiTenantPluginConfig + >['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', +}) diff --git a/packages/plugin-multi-tenant/src/index.ts b/packages/plugin-multi-tenant/src/index.ts index 4b47f245943..b9c6a54b395 100644 --- a/packages/plugin-multi-tenant/src/index.ts +++ b/packages/plugin-multi-tenant/src/index.ts @@ -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({ + 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, }, }) } diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx index bf66c2550d5..f4f609398c3 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.client.tsx @@ -65,18 +65,14 @@ const Context = createContext({ 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( initialValue, @@ -89,7 +85,7 @@ export const TenantSelectionProviderClient = ({ const prevUserID = React.useRef(userID) const userChanged = userID !== prevUserID.current const [tenantOptions, setTenantOptions] = React.useState( - () => 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) => ({ - label: doc[useAsTitle], - value: doc.id, - })), - ) - - if (result.totalDocs === 1) { - setSelectedTenantID(result.docs[0].id) - setCookie(String(result.docs[0].id)) + if (result.tenantOptions && userID) { + setTenantOptions(result.tenantOptions) + + 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( ({ id, label }) => { diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx index 844c70ca34a..73f902b8672 100644 --- a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx @@ -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 = { children: React.ReactNode payload: Payload + tenantsArrayFieldName: string + tenantsArrayTenantFieldName: string tenantsCollectionSlug: string useAsTitle: string user: TypedUser + userHasAccessToAllTenants: Required< + MultiTenantPluginConfig + >['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) => { + 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 ( {children} diff --git a/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts b/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts deleted file mode 100644 index 20f8f79bfe9..00000000000 --- a/packages/plugin-multi-tenant/src/queries/findTenantOptions.ts +++ /dev/null @@ -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 => { - 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, - }) -} diff --git a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts index 64b0acce938..ba5ebd15dfa 100644 --- a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts +++ b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts @@ -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>['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 { 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 { diff --git a/packages/plugin-multi-tenant/src/utilities/getTenantOptions.ts b/packages/plugin-multi-tenant/src/utilities/getTenantOptions.ts new file mode 100644 index 00000000000..f3a2d13e779 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/getTenantOptions.ts @@ -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>['userHasAccessToAllTenants'] +}): Promise => { + 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 +}