feat: users on tenants

This commit is contained in:
Jarrod Flesch
2025-07-09 11:41:14 -04:00
parent 855a320474
commit 204a2eacb7
9 changed files with 247 additions and 87 deletions

View File

@@ -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
*/

View File

@@ -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<null | Where> => {
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),
),
},
}
}

View File

@@ -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 extends readonly string[]> = T[number] extends keyof Omit<
Required<CollectionConfig>['access'],
@@ -56,3 +56,28 @@ export const addCollectionAccess = <ConfigType>({
})
})
}
export const addUserCollectionAccess = <ConfigType>({
adminUsersSlug,
collection,
fieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
}: Args<ConfigType>): void => {
collectionAccessKeys.forEach((key) => {
if (!collection.access) {
collection.access = {}
}
collection.access[key] = withUserTenantAccess<ConfigType>({
accessFunction: collection.access?.[key],
adminUsersSlug,
collection,
fieldName: key === 'readVersions' ? `version.${fieldName}` : fieldName,
operation: key,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
})
})
}

View File

@@ -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 || [],
},
}
}

View File

@@ -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 =
<ConfigType>({
accessFunction,
adminUsersSlug,
collection,
fieldName,
tenantsArrayFieldName,
tenantsArrayTenantFieldName,
userHasAccessToAllTenants,
}: Args<ConfigType>) =>
async (args: AccessArgs): Promise<AccessResult> => {
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<number | string>([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
}

View File

@@ -24,11 +24,5 @@ export const Tenants: CollectionConfig = {
type: 'text',
required: true,
},
{
type: 'join',
name: 'users',
collection: 'users',
on: 'tenants.tenant',
},
],
}

View File

@@ -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',
},
}

View File

@@ -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<T extends boolean = true> {
name?: T;
domain?: T;
users?: T;
assignedUsers?:
| T
| {
user?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
@@ -289,6 +300,7 @@ export interface UsersSelect<T extends boolean = true> {
tenant?: T;
id?: T;
};
joinedTenants?: T;
updatedAt?: T;
createdAt?: T;
email?: T;

View File

@@ -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,
},
],
},
})
}