feat: make auth based off user roles and tenant access, and tenant roles
This commit is contained in:
		
							parent
							
								
									eb2737fd5e
								
							
						
					
					
						commit
						723dda191b
					
				
							
								
								
									
										10
									
								
								src/access/isSuperAdmin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/access/isSuperAdmin.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					import type { Access } from 'payload'
 | 
				
			||||||
 | 
					import { User } from '../payload-types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const isSuperAdminAccess: Access = ({ req }): boolean => {
 | 
				
			||||||
 | 
					  return isSuperAdmin(req.user)
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const isSuperAdmin = (user: User | null): boolean => {
 | 
				
			||||||
 | 
					  return Boolean(user?.roles?.includes('super-admin'))
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										39
									
								
								src/collections/Tenants/access/byTenant.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/collections/Tenants/access/byTenant.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					import type { Access } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { isSuperAdmin } from '../../../access/isSuperAdmin'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const filterByTenantRead: Access = (args) => {
 | 
				
			||||||
 | 
					  // Allow public tenants to be read by anyone
 | 
				
			||||||
 | 
					  if (!args.req.user) {
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      allowPublicRead: {
 | 
				
			||||||
 | 
					        equals: true,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return true
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const canMutateTenant: Access = ({ req }) => {
 | 
				
			||||||
 | 
					  if (!req.user) {
 | 
				
			||||||
 | 
					    return false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (isSuperAdmin(req.user)) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    id: {
 | 
				
			||||||
 | 
					      in:
 | 
				
			||||||
 | 
					        req.user?.tenants
 | 
				
			||||||
 | 
					          ?.map(({ roles, tenant }) =>
 | 
				
			||||||
 | 
					            roles?.includes('tenant-admin')
 | 
				
			||||||
 | 
					              ? tenant && (typeof tenant === 'string' ? tenant : tenant.id)
 | 
				
			||||||
 | 
					              : null,
 | 
				
			||||||
 | 
					          )
 | 
				
			||||||
 | 
					          .filter(Boolean) || [],
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										19
									
								
								src/collections/Tenants/access/updateAndDelete.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/collections/Tenants/access/updateAndDelete.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
				
			|||||||
 | 
					import { isSuperAdmin } from '@/access/isSuperAdmin'
 | 
				
			||||||
 | 
					import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
 | 
				
			||||||
 | 
					import { Access } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const updateAndDeleteAccess: Access = ({ req }) => {
 | 
				
			||||||
 | 
					  if (!req.user) {
 | 
				
			||||||
 | 
					    return false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (isSuperAdmin(req.user)) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    id: {
 | 
				
			||||||
 | 
					      in: getUserTenantIDs(req.user, 'tenant-admin'),
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,15 +1,15 @@
 | 
				
			|||||||
import type { CollectionConfig } from 'payload'
 | 
					import type { CollectionConfig } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
//import { updateAndDeleteAccess } from './access/updateAndDelete'
 | 
					import { isSuperAdminAccess } from '@/access/isSuperAdmin'
 | 
				
			||||||
import { isSuperAdmin } from '@/access/admin'
 | 
					import { updateAndDeleteAccess } from './access/updateAndDelete'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const Tenants: CollectionConfig = {
 | 
					export const Tenants: CollectionConfig = {
 | 
				
			||||||
  slug: 'tenants',
 | 
					  slug: 'tenants',
 | 
				
			||||||
  access: {
 | 
					  access: {
 | 
				
			||||||
    create: isSuperAdmin,
 | 
					    create: isSuperAdminAccess,
 | 
				
			||||||
    delete: isSuperAdmin, // change these to the example soon!
 | 
					    delete: updateAndDeleteAccess,
 | 
				
			||||||
    read: ({ req }) => true,
 | 
					    read: ({ req }) => Boolean(req.user),
 | 
				
			||||||
    update: isSuperAdmin,
 | 
					    update: updateAndDeleteAccess,
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  admin: {
 | 
					  admin: {
 | 
				
			||||||
    useAsTitle: 'name',
 | 
					    useAsTitle: 'name',
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										24
									
								
								src/collections/Users/access/create.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/collections/Users/access/create.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,24 @@
 | 
				
			|||||||
 | 
					import type { Access } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import type { User } from '../../../payload-types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { isSuperAdmin } from '../../../access/isSuperAdmin'
 | 
				
			||||||
 | 
					import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const createAccess: Access<User> = ({ req }) => {
 | 
				
			||||||
 | 
					  if (!req.user) {
 | 
				
			||||||
 | 
					    return false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (isSuperAdmin(req.user)) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (adminTenantAccessIDs.length) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return false
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										5
									
								
								src/collections/Users/access/isAccessingSelf.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/collections/Users/access/isAccessingSelf.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,5 @@
 | 
				
			|||||||
 | 
					import { User } from '@/payload-types'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const isAccessingSelf = ({ id, user }: { user?: User; id?: string | number }): boolean => {
 | 
				
			||||||
 | 
					  return user ? Boolean(user.id === id) : false
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										56
									
								
								src/collections/Users/access/read.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								src/collections/Users/access/read.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,56 @@
 | 
				
			|||||||
 | 
					import type { User } from '@/payload-types'
 | 
				
			||||||
 | 
					import type { Access, Where } from 'payload'
 | 
				
			||||||
 | 
					import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { isSuperAdmin } from '../../../access/isSuperAdmin'
 | 
				
			||||||
 | 
					import { isAccessingSelf } from './isAccessingSelf'
 | 
				
			||||||
 | 
					import { getCollectionIDType } from '@/utilities/getCollectionIDType'
 | 
				
			||||||
 | 
					import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const readAccess: Access<User> = ({ req, id }) => {
 | 
				
			||||||
 | 
					  if (!req?.user) {
 | 
				
			||||||
 | 
					    return false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (isAccessingSelf({ id, user: req.user })) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const superAdmin = isSuperAdmin(req.user)
 | 
				
			||||||
 | 
					  const selectedTenant = getTenantFromCookie(
 | 
				
			||||||
 | 
					    req.headers,
 | 
				
			||||||
 | 
					    getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					  const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (selectedTenant) {
 | 
				
			||||||
 | 
					    // If it's a super admin, or they have access to the tenant ID set in cookie
 | 
				
			||||||
 | 
					    const hasTenantAccess = adminTenantAccessIDs.some((id) => id === selectedTenant)
 | 
				
			||||||
 | 
					    if (superAdmin || hasTenantAccess) {
 | 
				
			||||||
 | 
					      return {
 | 
				
			||||||
 | 
					        'tenants.tenant': {
 | 
				
			||||||
 | 
					          equals: selectedTenant,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (superAdmin) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    or: [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        id: {
 | 
				
			||||||
 | 
					          equals: req.user.id,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        'tenants.tenant': {
 | 
				
			||||||
 | 
					          in: adminTenantAccessIDs,
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    ],
 | 
				
			||||||
 | 
					  } as Where
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										31
									
								
								src/collections/Users/access/updateAndDelete.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/collections/Users/access/updateAndDelete.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
				
			|||||||
 | 
					import type { Access } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { isSuperAdmin } from '@/access/isSuperAdmin'
 | 
				
			||||||
 | 
					import { isAccessingSelf } from './isAccessingSelf'
 | 
				
			||||||
 | 
					import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const updateAndDeleteAccess: Access = ({ req, id }) => {
 | 
				
			||||||
 | 
					  const { user } = req
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (!user) {
 | 
				
			||||||
 | 
					    return false
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (isSuperAdmin(user) || isAccessingSelf({ user, id })) {
 | 
				
			||||||
 | 
					    return true
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * Constrains update and delete access to users that belong
 | 
				
			||||||
 | 
					   * to the same tenant as the tenant-admin making the request
 | 
				
			||||||
 | 
					   *
 | 
				
			||||||
 | 
					   * You may want to take this a step further with a beforeChange
 | 
				
			||||||
 | 
					   * hook to ensure that the a tenant-admin can only remove users
 | 
				
			||||||
 | 
					   * from their own tenant in the tenants array.
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  return {
 | 
				
			||||||
 | 
					    'tenants.tenant': {
 | 
				
			||||||
 | 
					      in: getUserTenantIDs(user, 'tenant-admin'),
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										130
									
								
								src/collections/Users/endpoints/externalUsersLogin.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								src/collections/Users/endpoints/externalUsersLogin.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,130 @@
 | 
				
			|||||||
 | 
					import type { Collection, Endpoint } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { headersWithCors } from '@payloadcms/next/utilities'
 | 
				
			||||||
 | 
					import { APIError, generatePayloadCookie } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					// A custom endpoint that can be reached by POST request
 | 
				
			||||||
 | 
					// at: /api/users/external-users/login
 | 
				
			||||||
 | 
					export const externalUsersLogin: Endpoint = {
 | 
				
			||||||
 | 
					  handler: async (req) => {
 | 
				
			||||||
 | 
					    let data: { [key: string]: string } = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      if (typeof req.json === 'function') {
 | 
				
			||||||
 | 
					        data = await req.json()
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      // swallow error, data is already empty object
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const { password, tenantSlug, tenantDomain, username } = data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!username || !password) {
 | 
				
			||||||
 | 
					      throw new APIError('Username and Password are required for login.', 400, null, true)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const fullTenant = (
 | 
				
			||||||
 | 
					      await req.payload.find({
 | 
				
			||||||
 | 
					        collection: 'tenants',
 | 
				
			||||||
 | 
					        where: tenantDomain
 | 
				
			||||||
 | 
					          ? {
 | 
				
			||||||
 | 
					            domain: {
 | 
				
			||||||
 | 
					              equals: tenantDomain,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					          : {
 | 
				
			||||||
 | 
					            slug: {
 | 
				
			||||||
 | 
					              equals: tenantSlug,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    ).docs[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const foundUser = await req.payload.find({
 | 
				
			||||||
 | 
					      collection: 'users',
 | 
				
			||||||
 | 
					      where: {
 | 
				
			||||||
 | 
					        or: [
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            and: [
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                email: {
 | 
				
			||||||
 | 
					                  equals: username,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                'tenants.tenant': {
 | 
				
			||||||
 | 
					                  equals: fullTenant?.id,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            and: [
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                username: {
 | 
				
			||||||
 | 
					                  equals: username,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					              {
 | 
				
			||||||
 | 
					                'tenants.tenant': {
 | 
				
			||||||
 | 
					                  equals: fullTenant?.id,
 | 
				
			||||||
 | 
					                },
 | 
				
			||||||
 | 
					              },
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (foundUser?.totalDocs > 0) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        const loginAttempt = await req.payload.login({
 | 
				
			||||||
 | 
					          collection: 'users',
 | 
				
			||||||
 | 
					          data: {
 | 
				
			||||||
 | 
					            email: foundUser?.docs[0]?.email || '',
 | 
				
			||||||
 | 
					            password,
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					          req,
 | 
				
			||||||
 | 
					        })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (loginAttempt?.token) {
 | 
				
			||||||
 | 
					          const collection: Collection = (req.payload.collections as { [key: string]: Collection })[
 | 
				
			||||||
 | 
					            'users'
 | 
				
			||||||
 | 
					          ]!
 | 
				
			||||||
 | 
					          const cookie = generatePayloadCookie({
 | 
				
			||||||
 | 
					            collectionAuthConfig: collection.config.auth,
 | 
				
			||||||
 | 
					            cookiePrefix: req.payload.config.cookiePrefix,
 | 
				
			||||||
 | 
					            token: loginAttempt.token,
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          return Response.json(loginAttempt, {
 | 
				
			||||||
 | 
					            headers: headersWithCors({
 | 
				
			||||||
 | 
					              headers: new Headers({
 | 
				
			||||||
 | 
					                'Set-Cookie': cookie,
 | 
				
			||||||
 | 
					              }),
 | 
				
			||||||
 | 
					              req,
 | 
				
			||||||
 | 
					            }),
 | 
				
			||||||
 | 
					            status: 200,
 | 
				
			||||||
 | 
					          })
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        throw new APIError(
 | 
				
			||||||
 | 
					          'Unable to login with the provided username and password.',
 | 
				
			||||||
 | 
					          400,
 | 
				
			||||||
 | 
					          null,
 | 
				
			||||||
 | 
					          true,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      } catch (e) {
 | 
				
			||||||
 | 
					        throw new APIError(
 | 
				
			||||||
 | 
					          'Unable to login with the provided username and password.',
 | 
				
			||||||
 | 
					          400,
 | 
				
			||||||
 | 
					          null,
 | 
				
			||||||
 | 
					          true,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    throw new APIError('Unable to login with the provided username and password.', 400, null, true)
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  method: 'post',
 | 
				
			||||||
 | 
					  path: '/external-users/login',
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										76
									
								
								src/collections/Users/hooks/ensureUniqueUsername.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/collections/Users/hooks/ensureUniqueUsername.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,76 @@
 | 
				
			|||||||
 | 
					import type { FieldHook, Where } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { ValidationError } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { extractID } from '@/utilities/extractID'
 | 
				
			||||||
 | 
					import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
 | 
				
			||||||
 | 
					import { getCollectionIDType } from '@/utilities/getCollectionIDType'
 | 
				
			||||||
 | 
					import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
 | 
				
			||||||
 | 
					  // if value is unchanged, skip validation
 | 
				
			||||||
 | 
					  if (originalDoc.username === value) {
 | 
				
			||||||
 | 
					    return value
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const constraints: Where[] = [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      username: {
 | 
				
			||||||
 | 
					        equals: value,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const selectedTenant = getTenantFromCookie(
 | 
				
			||||||
 | 
					    req.headers,
 | 
				
			||||||
 | 
					    getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (selectedTenant) {
 | 
				
			||||||
 | 
					    constraints.push({
 | 
				
			||||||
 | 
					      'tenants.tenant': {
 | 
				
			||||||
 | 
					        equals: selectedTenant,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const findDuplicateUsers = await req.payload.find({
 | 
				
			||||||
 | 
					    collection: 'users',
 | 
				
			||||||
 | 
					    where: {
 | 
				
			||||||
 | 
					      and: constraints,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (findDuplicateUsers.docs.length > 0 && req.user) {
 | 
				
			||||||
 | 
					    const tenantIDs = getUserTenantIDs(req.user)
 | 
				
			||||||
 | 
					    // if the user is an admin or has access to more than 1 tenant
 | 
				
			||||||
 | 
					    // provide a more specific error message
 | 
				
			||||||
 | 
					    if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
 | 
				
			||||||
 | 
					      const attemptedTenantChange = await req.payload.findByID({
 | 
				
			||||||
 | 
					        // @ts-ignore - selectedTenant will match DB ID type
 | 
				
			||||||
 | 
					        id: selectedTenant,
 | 
				
			||||||
 | 
					        collection: 'tenants',
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      throw new ValidationError({
 | 
				
			||||||
 | 
					        errors: [
 | 
				
			||||||
 | 
					          {
 | 
				
			||||||
 | 
					            message: `The "${attemptedTenantChange.name}" tenant already has a user with the username "${value}". Usernames must be unique per tenant.`,
 | 
				
			||||||
 | 
					            path: 'username',
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        ],
 | 
				
			||||||
 | 
					      })
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    throw new ValidationError({
 | 
				
			||||||
 | 
					      errors: [
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          message: `A user with the username ${value} already exists. Usernames must be unique per tenant.`,
 | 
				
			||||||
 | 
					          path: 'username',
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return value
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										39
									
								
								src/collections/Users/hooks/setCookieBasedOnDomain.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src/collections/Users/hooks/setCookieBasedOnDomain.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					import type { CollectionAfterLoginHook } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import { mergeHeaders, generateCookie, getCookieExpiration } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, user }) => {
 | 
				
			||||||
 | 
					  const relatedOrg = await req.payload.find({
 | 
				
			||||||
 | 
					    collection: 'tenants',
 | 
				
			||||||
 | 
					    depth: 0,
 | 
				
			||||||
 | 
					    limit: 1,
 | 
				
			||||||
 | 
					    where: {
 | 
				
			||||||
 | 
					      domain: {
 | 
				
			||||||
 | 
					        equals: req.headers.get('host'),
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // If a matching tenant is found, set the 'payload-tenant' cookie
 | 
				
			||||||
 | 
					  if (relatedOrg && relatedOrg.docs.length > 0) {
 | 
				
			||||||
 | 
					    const tenantCookie = generateCookie({
 | 
				
			||||||
 | 
					      name: 'payload-tenant',
 | 
				
			||||||
 | 
					      expires: getCookieExpiration({ seconds: 7200 }),
 | 
				
			||||||
 | 
					      path: '/',
 | 
				
			||||||
 | 
					      returnCookieAsObject: false,
 | 
				
			||||||
 | 
					      value: String(relatedOrg.docs[0].id),
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Merge existing responseHeaders with the new Set-Cookie header
 | 
				
			||||||
 | 
					    const newHeaders = new Headers({
 | 
				
			||||||
 | 
					      'Set-Cookie': tenantCookie as string,
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Ensure you merge existing response headers if they already exist
 | 
				
			||||||
 | 
					    req.responseHeaders = req.responseHeaders
 | 
				
			||||||
 | 
					      ? mergeHeaders(req.responseHeaders, newHeaders)
 | 
				
			||||||
 | 
					      : newHeaders
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return user
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,7 +1,12 @@
 | 
				
			|||||||
import type { CollectionConfig } from 'payload'
 | 
					import type { CollectionConfig } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { authenticated } from '../../access/authenticated'
 | 
					import { createAccess } from './access/create'
 | 
				
			||||||
import { isSuperAdmin } from '@/access/admin'
 | 
					import { readAccess } from './access/read'
 | 
				
			||||||
 | 
					import { updateAndDeleteAccess } from './access/updateAndDelete'
 | 
				
			||||||
 | 
					import { externalUsersLogin } from './endpoints/externalUsersLogin'
 | 
				
			||||||
 | 
					import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
 | 
				
			||||||
 | 
					import { isSuperAdmin } from '@/access/isSuperAdmin'
 | 
				
			||||||
 | 
					import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain'
 | 
				
			||||||
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
 | 
					import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export enum UserAccessLevel {
 | 
					export enum UserAccessLevel {
 | 
				
			||||||
@ -35,17 +40,17 @@ const defaultTenantArrayField = tenantsArrayField({
 | 
				
			|||||||
export const Users: CollectionConfig = {
 | 
					export const Users: CollectionConfig = {
 | 
				
			||||||
  slug: 'users',
 | 
					  slug: 'users',
 | 
				
			||||||
  access: {
 | 
					  access: {
 | 
				
			||||||
    admin: authenticated,
 | 
					    create: createAccess,
 | 
				
			||||||
    create: authenticated,
 | 
					    delete: updateAndDeleteAccess,
 | 
				
			||||||
    delete: authenticated,
 | 
					    read: readAccess,
 | 
				
			||||||
    read: authenticated,
 | 
					    update: updateAndDeleteAccess,
 | 
				
			||||||
    update: authenticated,
 | 
					 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  admin: {
 | 
					  admin: {
 | 
				
			||||||
    defaultColumns: ['name', 'email'],
 | 
					    defaultColumns: ['name', 'email'],
 | 
				
			||||||
    useAsTitle: 'username',
 | 
					    useAsTitle: 'username',
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  auth: true,
 | 
					  auth: true,
 | 
				
			||||||
 | 
					  endpoints: [externalUsersLogin],
 | 
				
			||||||
  fields: [
 | 
					  fields: [
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      admin: {
 | 
					      admin: {
 | 
				
			||||||
@ -56,18 +61,18 @@ export const Users: CollectionConfig = {
 | 
				
			|||||||
      defaultValue: ['guest'],
 | 
					      defaultValue: ['guest'],
 | 
				
			||||||
      hasMany: true,
 | 
					      hasMany: true,
 | 
				
			||||||
      options: ['full-access', 'super-admin', 'user', 'guest'],
 | 
					      options: ['full-access', 'super-admin', 'user', 'guest'],
 | 
				
			||||||
      //     access: {
 | 
					      access: {
 | 
				
			||||||
      //       update: ({ req }) => {
 | 
					        update: ({ req }) => {
 | 
				
			||||||
      //         return isSuperAdmin({req})
 | 
					          return isSuperAdmin(req.user)
 | 
				
			||||||
      //       },
 | 
					        },
 | 
				
			||||||
      //     },
 | 
					      },
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
      name: 'username',
 | 
					      name: 'username',
 | 
				
			||||||
      type: 'text',
 | 
					      type: 'text',
 | 
				
			||||||
      //      hooks: {
 | 
					      hooks: {
 | 
				
			||||||
      //        beforeValidate: [ensureUniqueUsername],
 | 
					        beforeValidate: [ensureUniqueUsername],
 | 
				
			||||||
      //      },
 | 
					      },
 | 
				
			||||||
      index: true,
 | 
					      index: true,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@ -85,4 +90,7 @@ export const Users: CollectionConfig = {
 | 
				
			|||||||
    },
 | 
					    },
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  timestamps: true,
 | 
					  timestamps: true,
 | 
				
			||||||
 | 
					  hooks: {
 | 
				
			||||||
 | 
					    afterLogin: [setCookieBasedOnDomain],
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										10
									
								
								src/utilities/extractID.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/utilities/extractID.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,10 @@
 | 
				
			|||||||
 | 
					import { Config } from '@/payload-types'
 | 
				
			||||||
 | 
					import type { CollectionSlug } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const extractID = <T extends Config['collections'][CollectionSlug]>(
 | 
				
			||||||
 | 
					  objectOrID: T | T['id'],
 | 
				
			||||||
 | 
					): T['id'] => {
 | 
				
			||||||
 | 
					  if (objectOrID && typeof objectOrID === 'object') return objectOrID.id
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return objectOrID
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										9
									
								
								src/utilities/getCollectionIDType.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/utilities/getCollectionIDType.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,9 @@
 | 
				
			|||||||
 | 
					import type { CollectionSlug, Payload } from 'payload'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					type Args = {
 | 
				
			||||||
 | 
					  collectionSlug: CollectionSlug
 | 
				
			||||||
 | 
					  payload: Payload
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					export const getCollectionIDType = ({ collectionSlug, payload }: Args): 'number' | 'text' => {
 | 
				
			||||||
 | 
					  return payload.collections[collectionSlug]?.customIDType ?? payload.db.defaultIDType
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										31
									
								
								src/utilities/getUserTenantIds.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/utilities/getUserTenantIds.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,31 @@
 | 
				
			|||||||
 | 
					import type { Tenant, User } from '@/payload-types'
 | 
				
			||||||
 | 
					import { extractID } from './extractID'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Returns array of all tenant IDs assigned to a user
 | 
				
			||||||
 | 
					 *
 | 
				
			||||||
 | 
					 * @param user - User object with tenants field
 | 
				
			||||||
 | 
					 * @param role - Optional role to filter by
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					export const getUserTenantIDs = (
 | 
				
			||||||
 | 
					  user: null | User,
 | 
				
			||||||
 | 
					  role?: NonNullable<User['tenants']>[number]['roles'][number],
 | 
				
			||||||
 | 
					): Tenant['id'][] => {
 | 
				
			||||||
 | 
					  if (!user) {
 | 
				
			||||||
 | 
					    return []
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    user?.tenants?.reduce<Tenant['id'][]>((acc, { roles, tenant }) => {
 | 
				
			||||||
 | 
					      if (role && !roles.includes(role)) {
 | 
				
			||||||
 | 
					        return acc
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (tenant) {
 | 
				
			||||||
 | 
					        acc.push(extractID(tenant))
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return acc
 | 
				
			||||||
 | 
					    }, []) || []
 | 
				
			||||||
 | 
					  )
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user