From 204a2eacb7afb12685ca3f9cd9a75798cdb865c4 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Wed, 9 Jul 2025 11:41:14 -0400 Subject: [PATCH] feat: users on tenants --- packages/plugin-multi-tenant/src/index.ts | 42 +++++-- .../filterUsersBySelectedTenant.ts | 31 ++++-- .../src/utilities/addCollectionAccess.ts | 27 ++++- .../src/utilities/getTenantAccess.ts | 9 +- .../src/utilities/withTenantAccess.ts | 82 ++++++++++++++ .../collections/Tenants.ts | 6 - test/plugin-multi-tenant/credentials.ts | 6 +- test/plugin-multi-tenant/payload-types.ts | 28 +++-- test/plugin-multi-tenant/seed/index.ts | 103 +++++++++--------- 9 files changed, 247 insertions(+), 87 deletions(-) diff --git a/packages/plugin-multi-tenant/src/index.ts b/packages/plugin-multi-tenant/src/index.ts index 4b47f24594..2febb332e1 100644 --- a/packages/plugin-multi-tenant/src/index.ts +++ b/packages/plugin-multi-tenant/src/index.ts @@ -14,7 +14,7 @@ import { filterDocumentsBySelectedTenant } from './list-filters/filterDocumentsB import { filterTenantsBySelectedTenant } from './list-filters/filterTenantsBySelectedTenant.js' import { filterUsersBySelectedTenant } from './list-filters/filterUsersBySelectedTenant.js' import { translations } from './translations/index.js' -import { addCollectionAccess } from './utilities/addCollectionAccess.js' +import { addCollectionAccess, addUserCollectionAccess } from './utilities/addCollectionAccess.js' import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js' import { combineListFilters } from './utilities/combineListFilters.js' @@ -130,10 +130,23 @@ export const multiTenantPlugin = ) } - addCollectionAccess({ + /** + * 🚨 V2 + */ + adminUsersCollection.fields.push({ + name: 'joinedTenants', + type: 'join', + admin: { + allowCreate: false, + }, + collection: tenantsCollectionSlug, + on: 'assignedUsers.user', + }) + + addUserCollectionAccess({ adminUsersSlug: adminUsersCollection.slug, collection: adminUsersCollection, - fieldName: `${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`, + fieldName: `joinedTenants`, tenantsArrayFieldName, tenantsArrayTenantFieldName, userHasAccessToAllTenants, @@ -146,11 +159,9 @@ export const multiTenantPlugin = adminUsersCollection.admin.baseListFilter = combineListFilters({ baseListFilter: adminUsersCollection.admin?.baseListFilter, - customFilter: (args) => - filterUsersBySelectedTenant({ + customFilter: async (args) => + await filterUsersBySelectedTenant({ req: args.req, - tenantsArrayFieldName, - tenantsArrayTenantFieldName, tenantsCollectionSlug, }), }) @@ -234,6 +245,23 @@ export const multiTenantPlugin = }) } + /** + * 🚨 V2 + */ + collection.fields.push({ + name: 'assignedUsers', + type: 'array', + fields: [ + { + name: 'user', + type: 'relationship', + hasMany: false, + relationTo: adminUsersCollection.slug, + required: true, + }, + ], + }) + /** * Add custom tenant field that watches and dispatches updates to the selector */ diff --git a/packages/plugin-multi-tenant/src/list-filters/filterUsersBySelectedTenant.ts b/packages/plugin-multi-tenant/src/list-filters/filterUsersBySelectedTenant.ts index 113e0a63a4..1de1d646b3 100644 --- a/packages/plugin-multi-tenant/src/list-filters/filterUsersBySelectedTenant.ts +++ b/packages/plugin-multi-tenant/src/list-filters/filterUsersBySelectedTenant.ts @@ -1,33 +1,44 @@ -import type { PayloadRequest, Where } from 'payload' +import type { PayloadRequest, TypeWithID, Where } from 'payload' + +import { extractID } from 'payload/shared' import { getCollectionIDType } from '../utilities/getCollectionIDType.js' import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js' type Args = { req: PayloadRequest - tenantsArrayFieldName: string - tenantsArrayTenantFieldName: string tenantsCollectionSlug: string } /** * Filter the list of users by the selected tenant */ -export const filterUsersBySelectedTenant = ({ +export const filterUsersBySelectedTenant = async ({ req, - tenantsArrayFieldName, - tenantsArrayTenantFieldName, tenantsCollectionSlug, -}: Args): null | Where => { +}: Args): Promise => { const idType = getCollectionIDType({ collectionSlug: tenantsCollectionSlug, payload: req.payload, }) const selectedTenant = getTenantFromCookie(req.headers, idType) - if (selectedTenant) { + if (req.user && selectedTenant) { + const doc = await req.payload.findByID({ + id: selectedTenant, + collection: tenantsCollectionSlug, + depth: 0, + req, + select: { + assignedUsers: true, + }, + user: req.user, + }) + return { - [`${tenantsArrayFieldName}.${tenantsArrayTenantFieldName}`]: { - in: [selectedTenant], + id: { + in: (Array.isArray(doc?.assignedUsers) ? doc.assignedUsers : []).map( + ({ user: tenantUser }: { user: TypeWithID }) => extractID(tenantUser), + ), }, } } diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts index ff35a04a6a..6bc50b9ca5 100644 --- a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts @@ -2,7 +2,7 @@ import type { CollectionConfig } from 'payload' import type { MultiTenantPluginConfig } from '../types.js' -import { withTenantAccess } from './withTenantAccess.js' +import { withTenantAccess, withUserTenantAccess } from './withTenantAccess.js' type AllAccessKeys = T[number] extends keyof Omit< Required['access'], @@ -56,3 +56,28 @@ export const addCollectionAccess = ({ }) }) } + +export const addUserCollectionAccess = ({ + adminUsersSlug, + collection, + fieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + userHasAccessToAllTenants, +}: Args): void => { + collectionAccessKeys.forEach((key) => { + if (!collection.access) { + collection.access = {} + } + collection.access[key] = withUserTenantAccess({ + accessFunction: collection.access?.[key], + adminUsersSlug, + collection, + fieldName: key === 'readVersions' ? `version.${fieldName}` : fieldName, + operation: key, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + userHasAccessToAllTenants, + }) + }) +} diff --git a/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts b/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts index b7cd2dc683..229f525ce0 100644 --- a/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts @@ -1,4 +1,4 @@ -import type { Where } from 'payload' +import type { TypeWithID, Where } from 'payload' import type { UserWithTenantsField } from '../types.js' @@ -22,9 +22,14 @@ export function getTenantAccess({ tenantsArrayTenantFieldName, }) + /** + * 🚨 V2 + */ + const joinedTenants = user.joinedTenants?.docs.map((doc: TypeWithID) => doc.id) || [] + return { [fieldName]: { - in: userAssignedTenantIDs || [], + in: joinedTenants || [], }, } } diff --git a/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts b/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts index 3d716a91e3..ffede7441c 100644 --- a/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts +++ b/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts @@ -5,9 +5,12 @@ import type { AllOperations, CollectionConfig, TypedUser, + TypeWithID, Where, } from 'payload' +import { extractID } from 'payload/shared' + import type { MultiTenantPluginConfig, UserWithTenantsField } from '../types.js' import { combineWhereConstraints } from './combineWhereConstraints.js' @@ -62,6 +65,7 @@ export const withTenantAccess = tenantsArrayTenantFieldName, user: args.req.user as UserWithTenantsField, }) + if (collection.slug === args.req.user.collection) { constraints.push({ or: [ @@ -81,3 +85,81 @@ export const withTenantAccess = return accessResult } + +export const withUserTenantAccess = + ({ + accessFunction, + adminUsersSlug, + collection, + fieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + userHasAccessToAllTenants, + }: Args) => + async (args: AccessArgs): Promise => { + const constraints: Where[] = [] + const accessFn = + typeof accessFunction === 'function' + ? accessFunction + : ({ req }: AccessArgs): AccessResult => Boolean(req.user) + const accessResult: AccessResult = await accessFn(args) + + if (accessResult === false) { + return false + } else if (accessResult && typeof accessResult === 'object') { + constraints.push(accessResult) + } + + if ( + args.req.user && + args.req.user.collection === adminUsersSlug && + !userHasAccessToAllTenants( + args.req.user as ConfigType extends { user: unknown } ? ConfigType['user'] : TypedUser, + ) + ) { + const tenantConstraint = getTenantAccess({ + fieldName, + tenantsArrayFieldName, + tenantsArrayTenantFieldName, + user: args.req.user as UserWithTenantsField, + }) + + const joinedTenants = args.req.user.joinedTenants?.docs.map((doc: TypeWithID) => doc.id) || [] + + const tenantUserIDs = await args.req.payload.find({ + collection: 'tenants', + depth: 0, + limit: 0, + req: args.req, + select: { + assignedUsers: true, + }, + user: args.req.user, + where: { + id: { + in: joinedTenants, + }, + }, + }) + const userIDs = new Set([args.req.user.id]) + tenantUserIDs.docs.forEach((doc: any) => { + doc.assignedUsers.forEach(({ user: tenantUser }: { user: TypeWithID }) => { + userIDs.add(extractID(tenantUser)) + }) + }) + + if (collection.slug === args.req.user.collection) { + constraints.push({ + id: { + in: Array.from(userIDs), + }, + }) + } else { + constraints.push(tenantConstraint) + } + + return combineWhereConstraints(constraints) + } + + return accessResult + } diff --git a/test/plugin-multi-tenant/collections/Tenants.ts b/test/plugin-multi-tenant/collections/Tenants.ts index f74b9210c6..6c11c179bb 100644 --- a/test/plugin-multi-tenant/collections/Tenants.ts +++ b/test/plugin-multi-tenant/collections/Tenants.ts @@ -24,11 +24,5 @@ export const Tenants: CollectionConfig = { type: 'text', required: true, }, - { - type: 'join', - name: 'users', - collection: 'users', - on: 'tenants.tenant', - }, ], } diff --git a/test/plugin-multi-tenant/credentials.ts b/test/plugin-multi-tenant/credentials.ts index a6c330a903..29292d9a17 100644 --- a/test/plugin-multi-tenant/credentials.ts +++ b/test/plugin-multi-tenant/credentials.ts @@ -7,8 +7,12 @@ export const credentials = { email: 'jane@blue-dog.com', password: 'test', }, + steelCat: { + email: 'huel@steel-cat.com', + password: 'test', + }, owner: { - email: 'owner@anchorAndBlueDog.com', + email: 'owner@AnchorAndBlueDog.com', password: 'test', }, } diff --git a/test/plugin-multi-tenant/payload-types.ts b/test/plugin-multi-tenant/payload-types.ts index 7fc6c31960..486e9cbc05 100644 --- a/test/plugin-multi-tenant/payload-types.ts +++ b/test/plugin-multi-tenant/payload-types.ts @@ -76,8 +76,8 @@ export interface Config { 'payload-migrations': PayloadMigration; }; collectionsJoins: { - tenants: { - users: 'users'; + users: { + joinedTenants: 'tenants'; }; }; collectionsSelect: { @@ -129,11 +129,12 @@ export interface Tenant { id: string; name: string; domain: string; - users?: { - docs?: (string | User)[]; - hasNextPage?: boolean; - totalDocs?: number; - }; + assignedUsers?: + | { + user: string | User; + id?: string | null; + }[] + | null; updatedAt: string; createdAt: string; } @@ -150,6 +151,11 @@ export interface User { id?: string | null; }[] | null; + joinedTenants?: { + docs?: (string | Tenant)[]; + hasNextPage?: boolean; + totalDocs?: number; + }; updatedAt: string; createdAt: string; email: string; @@ -273,7 +279,12 @@ export interface PayloadMigration { export interface TenantsSelect { name?: T; domain?: T; - users?: T; + assignedUsers?: + | T + | { + user?: T; + id?: T; + }; updatedAt?: T; createdAt?: T; } @@ -289,6 +300,7 @@ export interface UsersSelect { tenant?: T; id?: T; }; + joinedTenants?: T; updatedAt?: T; createdAt?: T; email?: T; diff --git a/test/plugin-multi-tenant/seed/index.ts b/test/plugin-multi-tenant/seed/index.ts index e4dacb0c20..e2040202bf 100644 --- a/test/plugin-multi-tenant/seed/index.ts +++ b/test/plugin-multi-tenant/seed/index.ts @@ -4,12 +4,53 @@ import { credentials } from '../credentials.js' import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from '../shared.js' export const seed: Config['onInit'] = async (payload) => { + // create users + await payload.create({ + collection: usersSlug, + data: { + ...credentials.admin, + roles: ['admin'], + }, + }) + + const blueDogUser = await payload.create({ + collection: usersSlug, + data: { + ...credentials.blueDog, + roles: ['user'], + }, + }) + + const blueDogAndAnchorUser = await payload.create({ + collection: usersSlug, + data: { + ...credentials.owner, + roles: ['user'], + }, + }) + + const steelCatUser = await payload.create({ + collection: usersSlug, + data: { + ...credentials.steelCat, + roles: ['user'], + }, + }) + // create tenants const blueDogTenant = await payload.create({ collection: tenantsSlug, data: { name: 'Blue Dog', domain: 'bluedog.com', + assignedUsers: [ + { + user: blueDogUser.id, + }, + { + user: blueDogAndAnchorUser.id, + }, + ], }, }) const steelCatTenant = await payload.create({ @@ -17,6 +58,11 @@ export const seed: Config['onInit'] = async (payload) => { data: { name: 'Steel Cat', domain: 'steelcat.com', + assignedUsers: [ + { + user: steelCatUser.id, + }, + ], }, }) const anchorBarTenant = await payload.create({ @@ -24,6 +70,11 @@ export const seed: Config['onInit'] = async (payload) => { data: { name: 'Anchor Bar', domain: 'anchorbar.com', + assignedUsers: [ + { + user: blueDogAndAnchorUser.id, + }, + ], }, }) @@ -73,44 +124,6 @@ export const seed: Config['onInit'] = async (payload) => { }, }) - // create users - await payload.create({ - collection: usersSlug, - data: { - ...credentials.admin, - roles: ['admin'], - }, - }) - - await payload.create({ - collection: usersSlug, - data: { - ...credentials.blueDog, - roles: ['user'], - tenants: [ - { - tenant: blueDogTenant.id, - }, - ], - }, - }) - - await payload.create({ - collection: usersSlug, - data: { - ...credentials.owner, - roles: ['user'], - tenants: [ - { - tenant: anchorBarTenant.id, - }, - { - tenant: blueDogTenant.id, - }, - ], - }, - }) - // create menus await payload.create({ collection: menuSlug, @@ -128,18 +141,4 @@ export const seed: Config['onInit'] = async (payload) => { tenant: steelCatTenant.id, }, }) - - await payload.create({ - collection: usersSlug, - data: { - email: 'huel@steel-cat.com', - password: 'test', - roles: ['user'], - tenants: [ - { - tenant: steelCatTenant.id, - }, - ], - }, - }) }