chore(examples): multi tenant single domain updates (#7149)

This commit is contained in:
Jarrod Flesch
2024-07-15 11:53:40 -04:00
committed by GitHub
parent 24f55c90c8
commit 598542dd51
22 changed files with 239 additions and 97 deletions

View File

@@ -3,7 +3,9 @@
* and uses the Payload Local API to query the database.
*/
import type { Payload } from 'payload';
process.env.PAYLOAD_DROP_DATABASE = 'true'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { importConfig } from 'payload/node'
@@ -106,7 +108,7 @@ async function run() {
password: 'test',
tenants: [
{
roles: ['super-admin'],
roles: ['tenant-admin'],
tenant: tenant1.id,
},
],
@@ -121,7 +123,7 @@ async function run() {
password: 'test',
tenants: [
{
roles: ['super-admin'],
roles: ['tenant-admin'],
tenant: tenant2.id,
},
],
@@ -136,11 +138,11 @@ async function run() {
password: 'test',
tenants: [
{
roles: ['super-admin'],
roles: ['tenant-admin'],
tenant: tenant1.id,
},
{
roles: ['super-admin'],
roles: ['tenant-admin'],
tenant: tenant2.id,
},
],

View File

@@ -2,10 +2,10 @@ import type { Access } from 'payload'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const byTenant: Access = (args) => {
export const filterByTenantRead: Access = (args) => {
const req = args.req
const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(args)
@@ -58,3 +58,34 @@ export const byTenant: Access = (args) => {
// Deny access to all others
return false
}
export const canMutatePage: Access = (args) => {
const req = args.req
const superAdmin = isSuperAdmin(args)
if (!req.user) return false
// super admins can mutate pages for any tenant
if (superAdmin) {
return true
}
const cookies = parseCookies(req.headers)
const selectedTenant = cookies.get('payload-tenant')
// tenant admins can add/delete/update
// pages they have access to
return (
req.user?.tenants?.reduce((hasAccess: boolean, accessRow) => {
if (hasAccess) return true
if (
accessRow &&
accessRow.tenant === selectedTenant &&
accessRow.roles?.includes('tenant-admin')
) {
return true
}
return hasAccess
}, false) || false
)
}

View File

@@ -2,7 +2,7 @@ import type { FieldHook } from 'payload'
import { ValidationError } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation

View File

@@ -1,25 +1,26 @@
import type { CollectionConfig } from 'payload'
import { tenantField } from '../../fields/TenantField'
import { isPayloadAdminPanel } from '../../utilities/isPayloadAdminPanel'
import { byTenant } from './access/byTenant'
import { externalReadAccess } from './access/externalReadAccess'
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
import { tenantField } from '../../fields/TenantField/index.js'
import { isPayloadAdminPanel } from '../../utilities/isPayloadAdminPanel.js'
import { canMutatePage, filterByTenantRead } from './access/byTenant.js'
import { externalReadAccess } from './access/externalReadAccess.js'
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug.js'
export const Pages: CollectionConfig = {
slug: 'pages',
access: {
delete: byTenant,
create: canMutatePage,
delete: canMutatePage,
read: (args) => {
// when viewing pages inside the admin panel
// restrict access to the ones your user has access to
if (isPayloadAdminPanel(args.req)) return byTenant(args)
if (isPayloadAdminPanel(args.req)) return filterByTenantRead(args)
// when viewing pages from outside the admin panel
// you should be able to see your tenants and public tenants
return externalReadAccess(args)
},
update: byTenant,
update: canMutatePage,
},
admin: {
useAsTitle: 'title',

View File

@@ -0,0 +1,66 @@
import type { Access } from 'payload'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const filterByTenantRead: Access = (args) => {
const req = args.req
// Super admin can read all
if (isSuperAdmin(args)) return true
const tenantIDs = getTenantAccessIDs(req.user)
// Allow public tenants to be read by anyone
const publicConstraint = {
public: {
equals: true,
},
}
// If a user has tenant ID access,
// return constraint to allow them to read those tenants
if (tenantIDs.length) {
return {
or: [
publicConstraint,
{
id: {
in: tenantIDs,
},
},
],
}
}
return publicConstraint
}
export const canMutateTenant: Access = (args) => {
const req = args.req
const superAdmin = isSuperAdmin(args)
if (!req.user) return false
// super admins can mutate pages for any tenant
if (superAdmin) {
return true
}
const cookies = parseCookies(req.headers)
return {
id: {
in:
req.user?.tenants
?.map(({ roles, tenant }) =>
roles?.includes('tenant-admin')
? tenant && (typeof tenant === 'string' ? tenant : tenant.id)
: null,
)
.filter(Boolean) || [],
},
}
}

View File

@@ -1,7 +1,7 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const tenantRead: Access = (args) => {
const req = args.req

View File

@@ -1,15 +1,15 @@
import type { CollectionConfig } from 'payload'
import { isSuperAdmin } from '../../access/isSuperAdmin'
import { tenantRead } from './access/read'
import { isSuperAdmin } from '../../access/isSuperAdmin.js'
import { canMutateTenant, filterByTenantRead } from './access/byTenant.js'
export const Tenants: CollectionConfig = {
slug: 'tenants',
access: {
create: isSuperAdmin,
delete: isSuperAdmin,
read: tenantRead,
update: isSuperAdmin,
delete: canMutateTenant,
read: filterByTenantRead,
update: canMutateTenant,
},
admin: {
useAsTitle: 'name',

View File

@@ -0,0 +1,19 @@
import type { Access } from 'payload'
import type { User } from '../../../../payload-types'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const createAccess: Access<User> = (args) => {
const { req } = args
if (!req.user) return false
if (isSuperAdmin(args)) return true
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
if (adminTenantAccessIDs.length > 0) return true
return false
}

View File

@@ -1,6 +1,6 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { isAccessingSelf } from './isAccessingSelf'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { isAccessingSelf } from './isAccessingSelf.js'
export const isSuperAdminOrSelf: Access = (args) => isSuperAdmin(args) || isAccessingSelf(args)

View File

@@ -0,0 +1,19 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const updateAndDeleteAccess: Access = (args) => {
const { req } = args
if (!req.user) return false
if (isSuperAdmin(args)) return true
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
return {
'tenants.tenant': {
in: adminTenantAccessIDs,
},
}
}

View File

@@ -2,7 +2,7 @@ import type { FieldHook } from 'payload'
import { ValidationError } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation

View File

@@ -1,28 +1,34 @@
import type { CollectionConfig } from 'payload'
import { isSuperAdmin } from '../../access/isSuperAdmin'
import { isSuperAdminOrSelf } from './access/isSuperAdminOrSelf'
import { externalUsersLogin } from './endpoints/externalUsersLogin'
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
import type { User } from '../../../payload-types'
import { isSuperAdmin } from '../../access/isSuperAdmin.js'
import { getTenantAdminTenantAccessIDs } from '../../utilities/getTenantAccessIDs.js'
import { createAccess } from './access/create.js'
import { updateAndDeleteAccess } from './access/updateAndDelete.js'
import { externalUsersLogin } from './endpoints/externalUsersLogin.js'
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername.js'
const Users: CollectionConfig = {
slug: 'users',
access: {
create: isSuperAdmin,
delete: isSuperAdmin,
create: createAccess,
delete: updateAndDeleteAccess,
read: (args) => {
const { req } = args
if (!req?.user) return false
if (isSuperAdmin(args)) return true
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
return {
id: {
equals: req.user.id,
'tenants.tenant': {
in: adminTenantAccessIDs,
},
}
},
update: isSuperAdminOrSelf,
update: updateAndDeleteAccess,
},
admin: {
useAsTitle: 'email',
@@ -40,32 +46,39 @@ const Users: CollectionConfig = {
{
name: 'tenants',
type: 'array',
access: {
create: ({ req }) => {
if (isSuperAdmin({ req })) return true
return false
},
update: ({ req }) => {
if (isSuperAdmin({ req })) return true
return false
},
},
fields: [
{
name: 'tenant',
type: 'relationship',
filterOptions: ({ user }) => {
if (user?.roles?.includes('super-admin'))
return {
id: {
exists: true,
},
}
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(user as User)
return {
id: {
in: adminTenantAccessIDs,
},
}
},
index: true,
relationTo: 'tenants',
required: true,
saveToJWT: true,
},
{
name: 'roles',
type: 'select',
defaultValue: ['viewer'],
defaultValue: ['tenant-viewer'],
hasMany: true,
options: ['super-admin', 'viewer'],
options: ['tenant-admin', 'tenant-viewer'],
required: true,
},
],
saveToJWT: true,
},
{
name: 'username',

View File

@@ -1,12 +1,11 @@
'use client'
import type { Option } from '@payloadcms/ui/elements/ReactSelect'
import type { OptionObject } from 'payload'
import { SelectInput, useAuth } from '@payloadcms/ui'
import React from 'react'
import type { Tenant, User } from '../../../payload-types'
import type { Tenant, User } from '../../../payload-types.js'
import './index.scss'

View File

@@ -1,7 +1,7 @@
import { cookies as getCookies } from 'next/headers'
import React from 'react'
import { TenantSelector } from './index.client'
import { TenantSelector } from './index.client.js'
export const TenantSelectorRSC = () => {
const cookies = getCookies()

View File

@@ -1,7 +1,7 @@
import type { FieldAccess } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { isSuperAdmin } from '../../../access/isSuperAdmin.js'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const tenantFieldUpdate: FieldAccess = (args) => {
const tenantIDs = getTenantAccessIDs(args.req.user)

View File

@@ -1,9 +1,9 @@
'use client'
import type { User } from 'payload/generated-types'
import { RelationshipField, useAuth, useFieldProps } from '@payloadcms/ui'
import React from 'react'
import type { User } from '../../../../payload-types.js'
export const TenantFieldComponent = () => {
const { user } = useAuth<User>()
const { path, readOnly } = useFieldProps()

View File

@@ -1,6 +1,6 @@
import type { FieldHook } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs.js'
export const autofillTenant: FieldHook = ({ req, value }) => {
// If there is no value,

View File

@@ -1,9 +1,9 @@
import type { Field } from 'payload'
import { isSuperAdmin } from '../../access/isSuperAdmin'
import { tenantFieldUpdate } from './access/update'
import { TenantFieldComponent } from './components/Field'
import { autofillTenant } from './hooks/autofillTenant'
import { isSuperAdmin } from '../../access/isSuperAdmin.js'
import { tenantFieldUpdate } from './access/update.js'
import { TenantFieldComponent } from './components/Field.js'
import { autofillTenant } from './hooks/autofillTenant.js'
export const tenantField: Field = {
name: 'tenant',

View File

@@ -2,10 +2,25 @@ import type { User } from '../../payload-types'
export const getTenantAccessIDs = (user: User | null): string[] => {
if (!user) return []
return user?.tenants?.reduce((acc: string[], { tenant }) => {
if (tenant) {
acc.push(typeof tenant === 'string' ? tenant : tenant.id)
}
return acc
}, []) || []
}
return (
user?.tenants?.reduce((acc: string[], { tenant }) => {
if (tenant) {
acc.push(typeof tenant === 'string' ? tenant : tenant.id)
}
return acc
}, []) || []
)
}
export const getTenantAdminTenantAccessIDs = (user: User | null): string[] => {
if (!user) return []
return (
user?.tenants?.reduce((acc: string[], { roles, tenant }) => {
if (roles.includes('tenant-admin') && tenant) {
acc.push(typeof tenant === 'string' ? tenant : tenant.id)
}
return acc
}, []) || []
)
}

View File

@@ -1,23 +0,0 @@
import type { Request } from 'express'
export function parseCookies(req: Request): { [key: string]: string } {
const list = {}
const rc = req.headers.cookie
if (rc) {
rc.split(';').forEach((cookie) => {
const parts = cookie.split('=')
const key = parts.shift().trim()
const encodedValue = parts.join('=')
try {
const decodedValue = decodeURI(encodedValue)
list[key] = decodedValue
} catch (e) {
// swallow e
}
})
}
return list
}

View File

@@ -69,8 +69,8 @@ export interface User {
roles?: ('super-admin' | 'user')[] | null;
tenants?:
| {
tenant?: (string | null) | Tenant;
roles?: ('super-admin' | 'viewer')[] | null;
tenant: string | Tenant;
roles: ('tenant-admin' | 'tenant-viewer')[];
id?: string | null;
}[]
| null;
@@ -131,4 +131,4 @@ export interface Auth {
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
}

View File

@@ -4,10 +4,10 @@ import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import { Pages } from './cms/collections/Pages'
import { Tenants } from './cms/collections/Tenants'
import Users from './cms/collections/Users'
import { TenantSelectorRSC } from './cms/components/TenantSelector/index'
import { Pages } from './cms/collections/Pages/index.js'
import { Tenants } from './cms/collections/Tenants/index.js'
import Users from './cms/collections/Users/index.js'
import { TenantSelectorRSC } from './cms/components/TenantSelector/index.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)