Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(templates): fix multi-tenant example and switch to postgres #10459

Closed
wants to merge 2 commits into from
Closed
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
chore(templates): fix multi-tenant example and switch to postgres
janbiasi committed Jan 8, 2025

Verified

This commit was signed with the committer’s verified signature.
janbiasi Jan R. Biasi
commit 35d72f4a74594c243540900841496fe6cc076701
13 changes: 11 additions & 2 deletions examples/multi-tenant/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
build
dist
# npm
node_modules
package-lock.json

# sensitive secrets
.env

# compiled output
.next
build
dist

# generated code
.generated-schema.graphql
15 changes: 15 additions & 0 deletions examples/multi-tenant/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
**/generated-schema.graphql
**/payload-types.ts
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp
**/docs/**
tsconfig.json
pnpm-lock.yaml
6 changes: 6 additions & 0 deletions examples/multi-tenant/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}
9 changes: 8 additions & 1 deletion examples/multi-tenant/package.json
Original file line number Diff line number Diff line change
@@ -9,14 +9,15 @@
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation && pnpm seed && next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:migrations": "payload migrate:create",
"generate:schema": "payload-graphql generate:schema",
"generate:types": "payload generate:types",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"seed": "npm run payload migrate:fresh",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/db-mongodb": "latest",
"@payloadcms/db-postgres": "latest",
"@payloadcms/next": "latest",
"@payloadcms/richtext-lexical": "latest",
"@payloadcms/ui": "latest",
@@ -42,5 +43,11 @@
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {
"react": "19.0.0",
"react-dom": "19.0.0"
}
}
}
3,322 changes: 2,087 additions & 1,235 deletions examples/multi-tenant/pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion examples/multi-tenant/src/access/isSuperAdmin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { userRole } from '@/collections/Users/roles'
import type { Access } from 'payload'

export const isSuperAdmin: Access = ({ req }) => {
if (!req?.user) {
return false
}
return Boolean(req.user.roles?.includes('super-admin'))
return Boolean(req.user.roles?.includes(userRole.SUPER_ADMIN))
}
11 changes: 4 additions & 7 deletions examples/multi-tenant/src/app/components/Login/client.page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
'use client'
import type { FormEvent } from 'react'

import { useRef, type FormEvent } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import React from 'react'

import './index.scss'

const baseClass = 'loginPage'

type Props = {
tenantSlug: string
}
export const Login = ({ tenantSlug }: Props) => {
const usernameRef = React.useRef<HTMLInputElement>(null)
const passwordRef = React.useRef<HTMLInputElement>(null)
const usernameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
const router = useRouter()
const routeParams = useParams()
const searchParams = useSearchParams()
@@ -55,7 +52,7 @@ export const Login = ({ tenantSlug }: Props) => {
}

return (
<div className={baseClass}>
<div className="loginPage">
<form onSubmit={handleSubmit}>
<div>
<label>
7 changes: 3 additions & 4 deletions examples/multi-tenant/src/app/components/RenderPage/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { Fragment } from 'react'
import type { Page } from '@payload-types'

import React from 'react'

export const RenderPage = ({ data }: { data: Page }) => {
return (
<React.Fragment>
<Fragment>
<h2>Here you can decide how you would like to render the page data!</h2>
<code>{JSON.stringify(data)}</code>
</React.Fragment>
</Fragment>
)
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import type { BaseListFilter } from 'payload'
import { parseCookies, type BaseListFilter } from 'payload'

import { isSuperAdmin } from '@/access/isSuperAdmin'
import { getTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { parseCookies } from 'payload'
import { TENANT_COOKIE_NAME } from '@/collections/Tenants/cookie'

export const baseListFilter: BaseListFilter = (args) => {
const req = args.req
const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(args)
const selectedTenant = cookies.get('payload-tenant')
const selectedTenant = cookies.get(TENANT_COOKIE_NAME)
const tenantAccessIDs = getTenantAccessIDs(req.user)

// if user is super admin or has access to the selected tenant
if (selectedTenant && (superAdmin || tenantAccessIDs.some((id) => id === selectedTenant))) {
if (
selectedTenant &&
(superAdmin || tenantAccessIDs.some((id) => String(id) === selectedTenant))
) {
// set a base filter for the list view
return {
tenant: {
36 changes: 24 additions & 12 deletions examples/multi-tenant/src/collections/Pages/access/byTenant.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import type { Access } from 'payload'
import { parseCookies, type Access } from 'payload'
import type { Page } from '@/payload-types'

import { parseCookies } from 'payload'
import { TENANT_COOKIE_NAME } from '@/collections/Tenants/cookie'
import { getTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { extractID } from '@/utilities/extractID'
import { tenantUserRole } from '@/collections/Users/roles'

import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'

export const filterByTenantRead: Access = (args) => {
export const filterByTenantRead: Access<Page> = (args) => {
const req = args.req
const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(args)
const selectedTenant = cookies.get('payload-tenant')
const selectedTenant = cookies.get(TENANT_COOKIE_NAME)

const tenantAccessIDs = getTenantAccessIDs(req.user)

@@ -25,7 +27,7 @@ export const filterByTenantRead: Access = (args) => {
}
}

const hasTenantAccess = tenantAccessIDs.some((id) => id === selectedTenant)
const hasTenantAccess = tenantAccessIDs.some((id) => String(id) === selectedTenant)

// If NOT super admin,
// give them access only if they have access to tenant ID set in cookie
@@ -59,7 +61,7 @@ export const filterByTenantRead: Access = (args) => {
return false
}

export const canMutatePage: Access = (args) => {
export const canMutatePage: Access<Page> = (args) => {
const req = args.req
const superAdmin = isSuperAdmin(args)

@@ -73,7 +75,14 @@ export const canMutatePage: Access = (args) => {
}

const cookies = parseCookies(req.headers)
const selectedTenant = cookies.get('payload-tenant')
const selectedTenant = cookies.get(TENANT_COOKIE_NAME)

if (!selectedTenant) {
// No access if no tenant is set and user is not a super admin
return false
}

const selectedTenantID = Number.parseInt(selectedTenant, 10)

// tenant admins can add/delete/update
// pages they have access to
@@ -82,10 +91,13 @@ export const canMutatePage: Access = (args) => {
if (hasAccess) {
return true
}

const accessRowTenantId = extractID(accessRow.tenant)

if (
accessRow &&
accessRow.tenant === selectedTenant &&
accessRow.roles?.includes('tenant-admin')
accessRowTenantId === selectedTenantID &&
accessRow.roles?.includes(tenantUserRole.TENANT_ADMIN)
) {
return true
}
16 changes: 9 additions & 7 deletions examples/multi-tenant/src/collections/Pages/access/readAccess.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import type { Access, Where } from 'payload'
import { parseCookies, type Access, type Where } from 'payload'

import { parseCookies } from 'payload'

import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { getTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { TENANT_COOKIE_NAME } from '@/collections/Tenants/cookie'

export const readAccess: Access = (args) => {
const req = args.req
const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(args)
const selectedTenant = cookies.get('payload-tenant')
const selectedTenant = cookies.get(TENANT_COOKIE_NAME)
const tenantAccessIDs = getTenantAccessIDs(req.user)

const publicPageConstraint: Where = {
@@ -19,7 +18,10 @@ export const readAccess: Access = (args) => {
}

// If it's a super admin or has access to the selected tenant
if (selectedTenant && (superAdmin || tenantAccessIDs.some((id) => id === selectedTenant))) {
if (
selectedTenant &&
(superAdmin || tenantAccessIDs.some((id) => String(id) === selectedTenant))
) {
// filter access by selected tenant
return {
or: [
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
import type { FieldHook } from 'payload'
import { ValidationError, type FieldHook } from 'payload'
import type { Page } from '@/payload-types'

import { ValidationError } from 'payload'
import { getTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { userRole } from '@/collections/Users/roles'

import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'

export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
export const ensureUniqueSlug: FieldHook<Page> = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation
if (originalDoc.slug === value) {
if (originalDoc?.slug === value) {
return value
}

const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant

const currentTenantID =
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant

const tenantIDToMatch = incomingTenantID || currentTenantID

if (!tenantIDToMatch) {
// Prevent admins to create pages without assigning a tenant
throw new ValidationError({
errors: [
{
message: `The entry with ID "${originalDoc?.id || data?.id || 'unknown'}" is missing a tenant assignment`,
path: 'tenant',
},
],
})
}

const findDuplicatePages = await req.payload.find({
collection: 'pages',
where: {
@@ -37,9 +51,9 @@ export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, valu
const tenantIDs = getTenantAccessIDs(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) {
if (req.user.roles?.includes(userRole.SUPER_ADMIN) || tenantIDs.length > 1) {
const attemptedTenantChange = await req.payload.findByID({
id: tenantIDToMatch,
id: tenantIDToMatch!,
collection: 'tenants',
})

2 changes: 1 addition & 1 deletion examples/multi-tenant/src/collections/Pages/index.ts
Original file line number Diff line number Diff line change
@@ -28,7 +28,7 @@ export const Pages: CollectionConfig = {
type: 'text',
defaultValue: 'home',
hooks: {
beforeValidate: [ensureUniqueSlug],
beforeChange: [ensureUniqueSlug],
},
index: true,
},
Original file line number Diff line number Diff line change
@@ -2,6 +2,8 @@ import type { Access } from 'payload'

import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { extractID } from '@/utilities/extractID'
import { tenantUserRole } from '@/collections/Users/roles'

export const filterByTenantRead: Access = (args) => {
const req = args.req
@@ -56,9 +58,7 @@ export const canMutateTenant: Access = (args) => {
in:
req.user?.tenants
?.map(({ roles, tenant }) =>
roles?.includes('tenant-admin')
? tenant && (typeof tenant === 'string' ? tenant : tenant.id)
: null,
roles?.includes(tenantUserRole.TENANT_ADMIN) ? tenant && extractID(tenant) : null,
)
.filter(Boolean) || [],
},
5 changes: 5 additions & 0 deletions examples/multi-tenant/src/collections/Tenants/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Cookie name for defining the currently active tenant within payload
* @internal
*/
export const TENANT_COOKIE_NAME = 'payload-tenant'
7 changes: 3 additions & 4 deletions examples/multi-tenant/src/collections/Users/access/create.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import type { Access } from 'payload'
import type { User } from '@/payload-types'

import type { User } from '../../../payload-types'

import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '@/utilities/getTenantAccessIDs'

export const createAccess: Access<User> = (args) => {
const { req } = args
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Access } from 'payload'

import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { isAccessingSelf } from './isAccessingSelf'

export const isSuperAdminOrSelf: Access = (args) => isSuperAdmin(args) || isAccessingSelf(args)
13 changes: 6 additions & 7 deletions examples/multi-tenant/src/collections/Users/access/read.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { parseCookies, type Access, type Where } from 'payload'
import type { User } from '@/payload-types'
import type { Access, Where } from 'payload'

import { parseCookies } from 'payload'

import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { TENANT_COOKIE_NAME } from '@/collections/Tenants/cookie'

export const readAccess: Access<User> = (args) => {
const { req } = args
@@ -14,7 +13,7 @@ export const readAccess: Access<User> = (args) => {

const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(args)
const selectedTenant = cookies.get('payload-tenant')
const selectedTenant = cookies.get(TENANT_COOKIE_NAME)

if (selectedTenant) {
// If it's a super admin,
@@ -28,7 +27,7 @@ export const readAccess: Access<User> = (args) => {
}

const tenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
const hasTenantAccess = tenantAccessIDs.some((id) => id === selectedTenant)
const hasTenantAccess = tenantAccessIDs.some((id) => String(id) === selectedTenant)

// If NOT super admin,
// give them access only if they have access to tenant ID set in cookie
Original file line number Diff line number Diff line change
@@ -3,25 +3,27 @@ import type { FieldHook } from 'payload'
import { ValidationError } from 'payload'

import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import type { User } from '@/payload-types'
import { userRole } from '../roles'

export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
export const ensureUniqueUsername: FieldHook<User> = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation
if (originalDoc.username === value) {
if (originalDoc?.username === value) {
return value
}

const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
const currentTenantID =
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant
const tenantIDToMatch = incomingTenantID || currentTenantID
const incomingTenantIds = data?.tenants?.map((entry) => entry.id).join(',') || ''
const currentTenantIds = originalDoc?.tenants?.map((entry) => entry.id).join(',') || ''

const tenantIDToMatch = incomingTenantIds.length > 0 ? incomingTenantIds : currentTenantIds

const findDuplicateUsers = await req.payload.find({
collection: 'users',
where: {
and: [
{
'tenants.tenant': {
equals: tenantIDToMatch,
in: tenantIDToMatch,
},
},
{
@@ -37,7 +39,7 @@ export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req,
const tenantIDs = getTenantAccessIDs(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) {
if (req.user.roles?.includes(userRole.SUPER_ADMIN) || tenantIDs.length > 1) {
const attemptedTenantChange = await req.payload.findByID({
id: tenantIDToMatch,
collection: 'tenants',
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import type { CollectionAfterLoginHook } from 'payload'

import { mergeHeaders } from '@payloadcms/next/utilities'
import { generateCookie, getCookieExpiration } from 'payload'
import { TENANT_COOKIE_NAME } from '@/collections/Tenants/cookie'

export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, user }) => {
const relatedOrg = await req.payload.find({
@@ -18,11 +19,11 @@ export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, us
// If a matching tenant is found, set the 'payload-tenant' cookie
if (relatedOrg && relatedOrg.docs.length > 0) {
const tenantCookie = generateCookie({
name: 'payload-tenant',
name: TENANT_COOKIE_NAME,
expires: getCookieExpiration({ seconds: 7200 }),
path: '/',
returnCookieAsObject: false,
value: relatedOrg.docs[0].id,
value: String(relatedOrg.docs[0].id),
})

// Merge existing responseHeaders with the new Set-Cookie header
9 changes: 5 additions & 4 deletions examples/multi-tenant/src/collections/Users/index.ts
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import { readAccess } from './access/read'
import { updateAndDeleteAccess } from './access/updateAndDelete'
import { externalUsersLogin } from './endpoints/externalUsersLogin'
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
import { tenantUserRole, tenantUserRoles, userRole, userRoles } from './roles'
// import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain'

const Users: CollectionConfig = {
@@ -24,9 +25,9 @@ const Users: CollectionConfig = {
{
name: 'roles',
type: 'select',
defaultValue: ['user'],
defaultValue: [userRole.USER],
hasMany: true,
options: ['super-admin', 'user'],
options: userRoles,
},
{
name: 'tenants',
@@ -43,9 +44,9 @@ const Users: CollectionConfig = {
{
name: 'roles',
type: 'select',
defaultValue: ['tenant-viewer'],
defaultValue: [tenantUserRole.TENANT_VIEWER],
hasMany: true,
options: ['tenant-admin', 'tenant-viewer'],
options: tenantUserRoles,
required: true,
},
],
35 changes: 35 additions & 0 deletions examples/multi-tenant/src/collections/Users/roles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Available role struct for user roles
*/
export const userRole = {
SUPER_ADMIN: 'super-admin',
USER: 'user',
} as const

/**
* All available tenant user roles
*/
export const userRoles = Object.values(userRole)

/**
* All available user role types
*/
export type UserRole = (typeof userRole)[keyof typeof userRole]

/**
* Available role struct for tenant user roles
*/
export const tenantUserRole = {
TENANT_ADMIN: 'tenant-admin',
TENANT_VIEWER: 'tenant-viewer',
} as const

/**
* All available tenant user roles
*/
export const tenantUserRoles = Object.values(tenantUserRole)

/**
* All available tenant user role types
*/
export type TenantUserRole = (typeof tenantUserRole)[keyof typeof tenantUserRole]
Original file line number Diff line number Diff line change
@@ -1,28 +1,28 @@
'use client'
import type { Option } from '@payloadcms/ui/elements/ReactSelect'
import type { OptionObject } from 'payload'

import { getTenantAdminTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { useCallback, useEffect, useState } from 'react'
import type { Option } from '@payloadcms/ui/elements/ReactSelect'
import { SelectInput, useAuth } from '@payloadcms/ui'
import type { OptionObject } from 'payload'
import * as qs from 'qs-esm'
import React from 'react'

import type { Tenant, User } from '../../payload-types'
import type { Tenant, User } from '@/payload-types'
import { getTenantAdminTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { TENANT_COOKIE_NAME } from '@/collections/Tenants/cookie'
import { userRole } from '@/collections/Users/roles'
import { extractID } from '@/utilities/extractID'

import './index.scss'

export const TenantSelector = ({ initialCookie }: { initialCookie?: string }) => {
const { user } = useAuth<User>()
const [options, setOptions] = React.useState<OptionObject[]>([])
const [options, setOptions] = useState<OptionObject[]>([])

const isSuperAdmin = user?.roles?.includes('super-admin')
const isSuperAdmin = user?.roles?.includes(userRole.SUPER_ADMIN)
const tenantIDs =
user?.tenants?.map(({ tenant }) => {
if (tenant) {
if (typeof tenant === 'string') {
return tenant
}
return tenant.id
return extractID(tenant)
}
}) || []

@@ -31,17 +31,17 @@ export const TenantSelector = ({ initialCookie }: { initialCookie?: string }) =>
document.cookie = name + '=' + (value || '') + expires + '; path=/'
}

const handleChange = React.useCallback((option: Option | Option[]) => {
const handleChange = useCallback((option: Option | Option[]) => {
if (!option) {
setCookie('payload-tenant', undefined)
setCookie(TENANT_COOKIE_NAME, undefined)
window.location.reload()
} else if ('value' in option) {
setCookie('payload-tenant', option.value as string)
setCookie(TENANT_COOKIE_NAME, option.value as string)
window.location.reload()
}
}, [])

React.useEffect(() => {
useEffect(() => {
const fetchTenants = async () => {
const adminOfTenants = getTenantAdminTenantAccessIDs(user ?? null)

@@ -65,10 +65,13 @@ export const TenantSelector = ({ initialCookie }: { initialCookie?: string }) =>
credentials: 'include',
}).then((res) => res.json())

const optionsToSet = res.docs.map((doc: Tenant) => ({ label: doc.name, value: doc.id }))
const optionsToSet = res.docs.map((doc: Tenant) => ({
label: doc.name,
value: doc.id,
}))

if (optionsToSet.length === 1) {
setCookie('payload-tenant', optionsToSet[0].value)
setCookie(TENANT_COOKIE_NAME, optionsToSet[0].value)
}
setOptions(optionsToSet)
}
Original file line number Diff line number Diff line change
@@ -2,8 +2,9 @@ import { cookies as getCookies } from 'next/headers'
import React from 'react'

import { TenantSelector } from './index.client'
import { TENANT_COOKIE_NAME } from '@/collections/Tenants/cookie'

export const TenantSelectorRSC = async () => {
const cookies = await getCookies()
return <TenantSelector initialCookie={cookies.get('payload-tenant')?.value} />
return <TenantSelector initialCookie={cookies.get(TENANT_COOKIE_NAME)?.value} />
}
7 changes: 7 additions & 0 deletions examples/multi-tenant/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
declare namespace NodeJS {
export interface ProcessEnv {
DATABASE_URI: string
PAYLOAD_SECRET: string
PAYLOAD_PUBLIC_SERVER_URL: string
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { Payload } from 'payload'

import { type FC } from 'react'
import { cookies as getCookies, headers as getHeaders } from 'next/headers'
import React from 'react'

import { TenantFieldComponentClient } from './Field.client'
import { userRole } from '@/collections/Users/roles'

export const TenantFieldComponent: React.FC<{
export const TenantFieldComponent: FC<{
path: string
payload: Payload
readOnly: boolean
@@ -17,7 +17,7 @@ export const TenantFieldComponent: React.FC<{
if (
user &&
((Array.isArray(user.tenants) && user.tenants.length > 1) ||
user?.roles?.includes('super-admin'))
user?.roles?.includes(userRole.SUPER_ADMIN))
) {
return (
<TenantFieldComponentClient
9 changes: 9 additions & 0 deletions examples/multi-tenant/src/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as migration_seed from './seed'

export const migrations = [
{
up: migration_seed.up,
down: migration_seed.down,
name: 'seed',
},
]
33 changes: 19 additions & 14 deletions examples/multi-tenant/src/migrations/seed.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { MigrateUpArgs } from '@payloadcms/db-mongodb'
import type { MigrateUpArgs } from '@payloadcms/db-postgres'
import { tenantUserRole, userRole } from '@/collections/Users/roles'

export async function up({ payload }: MigrateUpArgs): Promise<void> {
await payload.create({
collection: 'users',
data: {
email: 'demo@payloadcms.com',
password: 'demo',
roles: ['super-admin'],
roles: [userRole.SUPER_ADMIN],
},
})

@@ -47,7 +48,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
roles: [tenantUserRole.TENANT_ADMIN],
tenant: tenant1.id,
},
// {
@@ -58,60 +59,60 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
username: 'tenant1',
},
})

//
await payload.create({
collection: 'users',
data: {
email: 'tenant2@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
roles: [tenantUserRole.TENANT_ADMIN],
tenant: tenant2.id,
},
],
username: 'tenant2',
},
})

//
await payload.create({
collection: 'users',
data: {
email: 'tenant3@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
roles: [tenantUserRole.TENANT_ADMIN],
tenant: tenant3.id,
},
],
username: 'tenant3',
},
})

//
await payload.create({
collection: 'users',
data: {
email: 'multi-admin@payloadcms.com',
password: 'test',
tenants: [
{
roles: ['tenant-admin'],
roles: [tenantUserRole.TENANT_ADMIN],
tenant: tenant1.id,
},
{
roles: ['tenant-admin'],
roles: [tenantUserRole.TENANT_ADMIN],
tenant: tenant2.id,
},
{
roles: ['tenant-admin'],
roles: [tenantUserRole.TENANT_ADMIN],
tenant: tenant3.id,
},
],
username: 'tenant3',
},
})

//
await payload.create({
collection: 'pages',
data: {
@@ -120,7 +121,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
title: 'Page for Tenant 1',
},
})

//
await payload.create({
collection: 'pages',
data: {
@@ -129,7 +130,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
title: 'Page for Tenant 2',
},
})

//
await payload.create({
collection: 'pages',
data: {
@@ -139,3 +140,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
},
})
}

export async function down({}: MigrateUpArgs): Promise<void> {
// do nothing as we did not mutate anything
}
52 changes: 35 additions & 17 deletions examples/multi-tenant/src/payload-types.ts
Original file line number Diff line number Diff line change
@@ -28,17 +28,17 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
defaultIDType: number;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs?: {
jobs: {
tasks: unknown;
workflows?: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
@@ -64,10 +64,10 @@ export interface UserAuthOperations {
* via the `definition` "pages".
*/
export interface Page {
id: string;
id: number;
title?: string | null;
slug?: string | null;
tenant: string | Tenant;
tenant: number | Tenant;
updatedAt: string;
createdAt: string;
}
@@ -76,9 +76,21 @@ export interface Page {
* via the `definition` "tenants".
*/
export interface Tenant {
id: string;
id: number;
name: string;
domains?:
| {
domain: string;
id?: string | null;
}[]
| null;
/**
* Used for url paths, example: /tenant-slug/page-slug
*/
slug: string;
/**
* If checked, logging in is not required.
*/
public?: boolean | null;
updatedAt: string;
createdAt: string;
@@ -88,11 +100,11 @@ export interface Tenant {
* via the `definition` "users".
*/
export interface User {
id: string;
id: number;
roles?: ('super-admin' | 'user')[] | null;
tenants?:
| {
tenant: string | Tenant;
tenant: number | Tenant;
roles: ('tenant-admin' | 'tenant-viewer')[];
id?: string | null;
}[]
@@ -114,24 +126,24 @@ export interface User {
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
id: number;
document?:
| ({
relationTo: 'pages';
value: string | Page;
value: number | Page;
} | null)
| ({
relationTo: 'users';
value: string | User;
value: number | User;
} | null)
| ({
relationTo: 'tenants';
value: string | Tenant;
value: number | Tenant;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
updatedAt: string;
createdAt: string;
@@ -141,10 +153,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
id: number;
user: {
relationTo: 'users';
value: string | User;
value: number | User;
};
key?: string | null;
value?:
@@ -164,7 +176,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
@@ -211,6 +223,12 @@ export interface UsersSelect<T extends boolean = true> {
*/
export interface TenantsSelect<T extends boolean = true> {
name?: T;
domains?:
| T
| {
domain?: T;
id?: T;
};
slug?: T;
public?: T;
updatedAt?: T;
@@ -259,4 +277,4 @@ export interface Auth {

declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
}
10 changes: 7 additions & 3 deletions examples/multi-tenant/src/payload.config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'url'
import { Pages } from './collections/Pages'
import { Tenants } from './collections/Tenants'
import Users from './collections/Users'
import { migrations } from './migrations'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -20,8 +21,11 @@ export default buildConfig({
user: 'users',
},
collections: [Pages, Users, Tenants],
db: mongooseAdapter({
url: process.env.DATABASE_URI as string,
db: postgresAdapter({
prodMigrations: migrations,
pool: {
connectionString: process.env.DATABASE_URI as string,
},
}),
editor: lexicalEditor({}),
graphQL: {
6 changes: 6 additions & 0 deletions examples/multi-tenant/src/utilities/extractID.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Config } from '@/payload-types'
import type { CollectionSlug } from 'payload'

/**
* Use the passed ID or the value of the `.id` property in case of an object
* @template T The object or ID value type
* @param {T} objectOrID The object or ID
* @returns The ID
*/
export const extractID = <T extends Config['collections'][CollectionSlug]>(
objectOrID: T | T['id'],
): T['id'] => {
3 changes: 2 additions & 1 deletion examples/multi-tenant/src/utilities/getTenantAccessIDs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { tenantUserRole } from '@/collections/Users/roles'
import type { Tenant, User } from '../payload-types'
import { extractID } from './extractID'

@@ -22,7 +23,7 @@ export const getTenantAdminTenantAccessIDs = (user: null | User): Tenant['id'][]

return (
user?.tenants?.reduce<Tenant['id'][]>((acc, { roles, tenant }) => {
if (roles.includes('tenant-admin') && tenant) {
if (roles.includes(tenantUserRole.TENANT_ADMIN) && tenant) {
acc.push(extractID(tenant))
}
return acc