chore(examples): multi tenant single domain updates (#7149)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}, []) || []
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user