diff --git a/src/access/isSuperAdmin.ts b/src/access/isSuperAdmin.ts new file mode 100644 index 0000000..f449243 --- /dev/null +++ b/src/access/isSuperAdmin.ts @@ -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')) +} diff --git a/src/collections/Tenants/access/byTenant.ts b/src/collections/Tenants/access/byTenant.ts new file mode 100644 index 0000000..05fc99a --- /dev/null +++ b/src/collections/Tenants/access/byTenant.ts @@ -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) || [], + }, + } +} diff --git a/src/collections/Tenants/access/updateAndDelete.ts b/src/collections/Tenants/access/updateAndDelete.ts new file mode 100644 index 0000000..2a51e96 --- /dev/null +++ b/src/collections/Tenants/access/updateAndDelete.ts @@ -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'), + }, + } +} diff --git a/src/collections/Tenants/index.ts b/src/collections/Tenants/index.ts index af6a368..f1b614e 100644 --- a/src/collections/Tenants/index.ts +++ b/src/collections/Tenants/index.ts @@ -1,15 +1,15 @@ import type { CollectionConfig } from 'payload' -//import { updateAndDeleteAccess } from './access/updateAndDelete' -import { isSuperAdmin } from '@/access/admin' +import { isSuperAdminAccess } from '@/access/isSuperAdmin' +import { updateAndDeleteAccess } from './access/updateAndDelete' export const Tenants: CollectionConfig = { slug: 'tenants', access: { - create: isSuperAdmin, - delete: isSuperAdmin, // change these to the example soon! - read: ({ req }) => true, - update: isSuperAdmin, + create: isSuperAdminAccess, + delete: updateAndDeleteAccess, + read: ({ req }) => Boolean(req.user), + update: updateAndDeleteAccess, }, admin: { useAsTitle: 'name', diff --git a/src/collections/Users/access/create.ts b/src/collections/Users/access/create.ts new file mode 100644 index 0000000..ecdf214 --- /dev/null +++ b/src/collections/Users/access/create.ts @@ -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 = ({ 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 +} diff --git a/src/collections/Users/access/isAccessingSelf.ts b/src/collections/Users/access/isAccessingSelf.ts new file mode 100644 index 0000000..980a8b1 --- /dev/null +++ b/src/collections/Users/access/isAccessingSelf.ts @@ -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 +} diff --git a/src/collections/Users/access/read.ts b/src/collections/Users/access/read.ts new file mode 100644 index 0000000..14ca302 --- /dev/null +++ b/src/collections/Users/access/read.ts @@ -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 = ({ 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 +} diff --git a/src/collections/Users/access/updateAndDelete.ts b/src/collections/Users/access/updateAndDelete.ts new file mode 100644 index 0000000..018ec3f --- /dev/null +++ b/src/collections/Users/access/updateAndDelete.ts @@ -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'), + }, + } +} diff --git a/src/collections/Users/endpoints/externalUsersLogin.ts b/src/collections/Users/endpoints/externalUsersLogin.ts new file mode 100644 index 0000000..6b770f2 --- /dev/null +++ b/src/collections/Users/endpoints/externalUsersLogin.ts @@ -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', +} diff --git a/src/collections/Users/hooks/ensureUniqueUsername.ts b/src/collections/Users/hooks/ensureUniqueUsername.ts new file mode 100644 index 0000000..ab51e4d --- /dev/null +++ b/src/collections/Users/hooks/ensureUniqueUsername.ts @@ -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 +} diff --git a/src/collections/Users/hooks/setCookieBasedOnDomain.ts b/src/collections/Users/hooks/setCookieBasedOnDomain.ts new file mode 100644 index 0000000..fe01891 --- /dev/null +++ b/src/collections/Users/hooks/setCookieBasedOnDomain.ts @@ -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 +} diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts index 988a2b6..2e1bed3 100644 --- a/src/collections/Users/index.ts +++ b/src/collections/Users/index.ts @@ -1,7 +1,12 @@ import type { CollectionConfig } from 'payload' -import { authenticated } from '../../access/authenticated' -import { isSuperAdmin } from '@/access/admin' +import { createAccess } from './access/create' +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' export enum UserAccessLevel { @@ -35,17 +40,17 @@ const defaultTenantArrayField = tenantsArrayField({ export const Users: CollectionConfig = { slug: 'users', access: { - admin: authenticated, - create: authenticated, - delete: authenticated, - read: authenticated, - update: authenticated, + create: createAccess, + delete: updateAndDeleteAccess, + read: readAccess, + update: updateAndDeleteAccess, }, admin: { defaultColumns: ['name', 'email'], useAsTitle: 'username', }, auth: true, + endpoints: [externalUsersLogin], fields: [ { admin: { @@ -56,18 +61,18 @@ export const Users: CollectionConfig = { defaultValue: ['guest'], hasMany: true, options: ['full-access', 'super-admin', 'user', 'guest'], - // access: { - // update: ({ req }) => { - // return isSuperAdmin({req}) - // }, - // }, + access: { + update: ({ req }) => { + return isSuperAdmin(req.user) + }, + }, }, { name: 'username', type: 'text', - // hooks: { - // beforeValidate: [ensureUniqueUsername], - // }, + hooks: { + beforeValidate: [ensureUniqueUsername], + }, index: true, }, { @@ -85,4 +90,7 @@ export const Users: CollectionConfig = { }, ], timestamps: true, + hooks: { + afterLogin: [setCookieBasedOnDomain], + }, } diff --git a/src/utilities/extractID.ts b/src/utilities/extractID.ts new file mode 100644 index 0000000..fe97a16 --- /dev/null +++ b/src/utilities/extractID.ts @@ -0,0 +1,10 @@ +import { Config } from '@/payload-types' +import type { CollectionSlug } from 'payload' + +export const extractID = ( + objectOrID: T | T['id'], +): T['id'] => { + if (objectOrID && typeof objectOrID === 'object') return objectOrID.id + + return objectOrID +} diff --git a/src/utilities/getCollectionIDType.ts b/src/utilities/getCollectionIDType.ts new file mode 100644 index 0000000..02ed72f --- /dev/null +++ b/src/utilities/getCollectionIDType.ts @@ -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 +} diff --git a/src/utilities/getUserTenantIds.ts b/src/utilities/getUserTenantIds.ts new file mode 100644 index 0000000..a2e7b48 --- /dev/null +++ b/src/utilities/getUserTenantIds.ts @@ -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[number]['roles'][number], +): Tenant['id'][] => { + if (!user) { + return [] + } + + return ( + user?.tenants?.reduce((acc, { roles, tenant }) => { + if (role && !roles.includes(role)) { + return acc + } + + if (tenant) { + acc.push(extractID(tenant)) + } + + return acc + }, []) || [] + ) +}