fix(examples): ensure working multi-tenant example with pg (#11501)

### What?
There were a couple issues with the implementation within the example
when using postgres.
- `ensureUniqueUsername` tenant was being extracted incorrectly, should
not constrain query unless it was present
- `ensureUniqueSlug` was querying by NaN when tenant was not present on
data or originalDoc
- `users` read access was not correctly extracting the tenant id in the
correct type depending on DB

Fixes https://github.com/payloadcms/payload/issues/11484
This commit is contained in:
Jarrod Flesch
2025-03-03 10:21:55 -05:00
committed by GitHub
parent 562acb7492
commit 4ddf96502c
15 changed files with 1099 additions and 76 deletions

View File

@@ -1,3 +1,5 @@
DATABASE_URI=mongodb://127.0.0.1/payload-example-multi-tenant DATABASE_URI=mongodb://127.0.0.1/payload-example-multi-tenant
POSTGRES_URL=postgres://127.0.0.1:5432/payload-example-multi-tenant
PAYLOAD_SECRET=PAYLOAD_MULTI_TENANT_EXAMPLE_SECRET_KEY PAYLOAD_SECRET=PAYLOAD_MULTI_TENANT_EXAMPLE_SECRET_KEY
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000 PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
SEED_DB=true

View File

@@ -46,12 +46,12 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
**Domain-based Tenant Setting**: **Domain-based Tenant Setting**:
This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.test:3000`), when a user logs in from that domain, they will be automatically scoped to the matching tenant. This is accomplished through an optional `afterLogin` hook that sets a `payload-tenant` cookie based on the domain. This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.localhost:3000`), when a user logs in from that domain, they will be automatically scoped to the matching tenant. This is accomplished through an optional `afterLogin` hook that sets a `payload-tenant` cookie based on the domain.
For the domain portion of the example to function properly, you will need to add the following entries to your system's `/etc/hosts` file: For the domain portion of the example to function properly, you will need to add the following entries to your system's `/etc/hosts` file:
``` ```
127.0.0.1 gold.test silver.test bronze.test 127.0.0.1 gold.localhost silver.localhost bronze.localhost
``` ```
- #### Pages - #### Pages

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -17,6 +17,7 @@
}, },
"dependencies": { "dependencies": {
"@payloadcms/db-mongodb": "latest", "@payloadcms/db-mongodb": "latest",
"@payloadcms/db-postgres": "^3.25.0",
"@payloadcms/next": "latest", "@payloadcms/next": "latest",
"@payloadcms/plugin-multi-tenant": "latest", "@payloadcms/plugin-multi-tenant": "latest",
"@payloadcms/richtext-lexical": "latest", "@payloadcms/richtext-lexical": "latest",

File diff suppressed because it is too large Load Diff

View File

@@ -10,10 +10,10 @@ export default async ({ params: paramsPromise }: { params: Promise<{ slug: strin
<p>When you visit a tenant by domain, the domain is used to determine the tenant.</p> <p>When you visit a tenant by domain, the domain is used to determine the tenant.</p>
<p> <p>
For example, visiting{' '} For example, visiting{' '}
<a href="http://gold.test:3000/tenant-domains/login"> <a href="http://gold.localhost:3000/tenant-domains/login">
http://gold.test:3000/tenant-domains/login http://gold.localhost:3000/tenant-domains/login
</a>{' '} </a>{' '}
will show the tenant with the domain "gold.test". will show the tenant with the domain "gold.localhost".
</p> </p>
<h2>Slugs</h2> <h2>Slugs</h2>

View File

@@ -3,10 +3,7 @@ import { TenantSelector as TenantSelector_1d0591e3cf4f332c83a86da13a0de59a } fro
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc' import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
export const importMap = { export const importMap = {
'@payloadcms/plugin-multi-tenant/client#TenantField': "@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
TenantField_1d0591e3cf4f332c83a86da13a0de59a, "@payloadcms/plugin-multi-tenant/client#TenantSelector": TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
'@payloadcms/plugin-multi-tenant/client#TenantSelector': "@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
'@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider':
TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62,
} }

View File

@@ -1,8 +1,9 @@
import type { FieldHook } from 'payload' import type { FieldHook, Where } from 'payload'
import { ValidationError } from 'payload' import { ValidationError } from 'payload'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs' import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
import { extractID } from '@/utilities/extractID'
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => { export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation // if value is unchanged, skip validation
@@ -10,26 +11,30 @@ export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, valu
return value return value
} }
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant const constraints: Where[] = [
const currentTenantID = {
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant slug: {
equals: value,
},
},
]
const incomingTenantID = extractID(data?.tenant)
const currentTenantID = extractID(originalDoc?.tenant)
const tenantIDToMatch = incomingTenantID || currentTenantID const tenantIDToMatch = incomingTenantID || currentTenantID
if (tenantIDToMatch) {
constraints.push({
tenant: {
equals: tenantIDToMatch,
},
})
}
const findDuplicatePages = await req.payload.find({ const findDuplicatePages = await req.payload.find({
collection: 'pages', collection: 'pages',
where: { where: {
and: [ and: constraints,
{
tenant: {
equals: tenantIDToMatch,
},
},
{
slug: {
equals: value,
},
},
],
}, },
}) })

View File

@@ -1,11 +1,11 @@
import type { User } from '@/payload-types' import type { User } from '@/payload-types'
import type { Access, Where } from 'payload' import type { Access, Where } from 'payload'
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin' import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs' import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
import { isAccessingSelf } from './isAccessingSelf' import { isAccessingSelf } from './isAccessingSelf'
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
export const readAccess: Access<User> = ({ req, id }) => { export const readAccess: Access<User> = ({ req, id }) => {
if (!req?.user) { if (!req?.user) {
@@ -16,9 +16,11 @@ export const readAccess: Access<User> = ({ req, id }) => {
return true return true
} }
const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(req.user) const superAdmin = isSuperAdmin(req.user)
const selectedTenant = cookies.get('payload-tenant') const selectedTenant = getTenantFromCookie(
req.headers,
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
)
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin') const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
if (selectedTenant) { if (selectedTenant) {

View File

@@ -1,8 +1,11 @@
import type { FieldHook } from 'payload' import type { FieldHook, Where } from 'payload'
import { ValidationError } from 'payload' import { ValidationError } from 'payload'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs' import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
import { extractID } from '@/utilities/extractID'
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => { export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation // if value is unchanged, skip validation
@@ -10,26 +13,31 @@ export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req,
return value return value
} }
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant const constraints: Where[] = [
const currentTenantID = {
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant username: {
const tenantIDToMatch = incomingTenantID || currentTenantID 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({ const findDuplicateUsers = await req.payload.find({
collection: 'users', collection: 'users',
where: { where: {
and: [ and: constraints,
{
'tenants.tenant': {
equals: tenantIDToMatch,
},
},
{
username: {
equals: value,
},
},
],
}, },
}) })
@@ -39,7 +47,8 @@ export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req,
// provide a more specific error message // provide a more specific error message
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) { if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
const attemptedTenantChange = await req.payload.findByID({ const attemptedTenantChange = await req.payload.findByID({
id: tenantIDToMatch, // @ts-ignore - selectedTenant will match DB ID type
id: selectedTenant,
collection: 'tenants', collection: 'tenants',
}) })

View File

@@ -21,7 +21,7 @@ export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, us
expires: getCookieExpiration({ seconds: 7200 }), expires: getCookieExpiration({ seconds: 7200 }),
path: '/', path: '/',
returnCookieAsObject: false, returnCookieAsObject: false,
value: relatedOrg.docs[0].id, value: String(relatedOrg.docs[0].id),
}) })
// Merge existing responseHeaders with the new Set-Cookie header // Merge existing responseHeaders with the new Set-Cookie header

View File

@@ -6,10 +6,65 @@
* and re-run `payload generate:types` to regenerate this file. * and re-run `payload generate:types` to regenerate this file.
*/ */
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config { export interface Config {
auth: { auth: {
users: UserAuthOperations; users: UserAuthOperations;
}; };
blocks: {};
collections: { collections: {
pages: Page; pages: Page;
users: User; users: User;
@@ -28,7 +83,7 @@ export interface Config {
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>; 'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
}; };
db: { db: {
defaultIDType: string; defaultIDType: number;
}; };
globals: {}; globals: {};
globalsSelect: {}; globalsSelect: {};
@@ -64,8 +119,8 @@ export interface UserAuthOperations {
* via the `definition` "pages". * via the `definition` "pages".
*/ */
export interface Page { export interface Page {
id: string; id: number;
tenant?: (string | null) | Tenant; tenant?: (number | null) | Tenant;
title?: string | null; title?: string | null;
slug?: string | null; slug?: string | null;
updatedAt: string; updatedAt: string;
@@ -76,7 +131,7 @@ export interface Page {
* via the `definition` "tenants". * via the `definition` "tenants".
*/ */
export interface Tenant { export interface Tenant {
id: string; id: number;
name: string; name: string;
/** /**
* Used for domain-based tenant handling * Used for domain-based tenant handling
@@ -98,12 +153,12 @@ export interface Tenant {
* via the `definition` "users". * via the `definition` "users".
*/ */
export interface User { export interface User {
id: string; id: number;
roles?: ('super-admin' | 'user')[] | null; roles?: ('super-admin' | 'user')[] | null;
username?: string | null; username?: string | null;
tenants?: tenants?:
| { | {
tenant: string | Tenant; tenant: number | Tenant;
roles: ('tenant-admin' | 'tenant-viewer')[]; roles: ('tenant-admin' | 'tenant-viewer')[];
id?: string | null; id?: string | null;
}[] }[]
@@ -124,24 +179,24 @@ export interface User {
* via the `definition` "payload-locked-documents". * via the `definition` "payload-locked-documents".
*/ */
export interface PayloadLockedDocument { export interface PayloadLockedDocument {
id: string; id: number;
document?: document?:
| ({ | ({
relationTo: 'pages'; relationTo: 'pages';
value: string | Page; value: number | Page;
} | null) } | null)
| ({ | ({
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
} | null) } | null)
| ({ | ({
relationTo: 'tenants'; relationTo: 'tenants';
value: string | Tenant; value: number | Tenant;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@@ -151,10 +206,10 @@ export interface PayloadLockedDocument {
* via the `definition` "payload-preferences". * via the `definition` "payload-preferences".
*/ */
export interface PayloadPreference { export interface PayloadPreference {
id: string; id: number;
user: { user: {
relationTo: 'users'; relationTo: 'users';
value: string | User; value: number | User;
}; };
key?: string | null; key?: string | null;
value?: value?:
@@ -174,7 +229,7 @@ export interface PayloadPreference {
* via the `definition` "payload-migrations". * via the `definition` "payload-migrations".
*/ */
export interface PayloadMigration { export interface PayloadMigration {
id: string; id: number;
name?: string | null; name?: string | null;
batch?: number | null; batch?: number | null;
updatedAt: string; updatedAt: string;

View File

@@ -1,4 +1,5 @@
import { mongooseAdapter } from '@payloadcms/db-mongodb' import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical' import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path' import path from 'path'
import { buildConfig } from 'payload' import { buildConfig } from 'payload'
@@ -11,6 +12,7 @@ import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import { isSuperAdmin } from './access/isSuperAdmin' import { isSuperAdmin } from './access/isSuperAdmin'
import type { Config } from './payload-types' import type { Config } from './payload-types'
import { getUserTenantIDs } from './utilities/getUserTenantIDs' import { getUserTenantIDs } from './utilities/getUserTenantIDs'
import { seed } from './seed'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
@@ -21,9 +23,19 @@ export default buildConfig({
user: 'users', user: 'users',
}, },
collections: [Pages, Users, Tenants], collections: [Pages, Users, Tenants],
db: mongooseAdapter({ // db: mongooseAdapter({
url: process.env.DATABASE_URI as string, // url: process.env.DATABASE_URI as string,
// }),
db: postgresAdapter({
pool: {
connectionString: process.env.POSTGRES_URL,
},
}), }),
onInit: async (args) => {
if (process.env.SEED_DB) {
await seed(args)
}
},
editor: lexicalEditor({}), editor: lexicalEditor({}),
graphQL: { graphQL: {
schemaOutputFile: path.resolve(dirname, 'generated-schema.graphql'), schemaOutputFile: path.resolve(dirname, 'generated-schema.graphql'),

View File

@@ -1,12 +1,12 @@
import type { MigrateUpArgs } from '@payloadcms/db-mongodb' import { Config } from 'payload'
export async function up({ payload }: MigrateUpArgs): Promise<void> { export const seed: NonNullable<Config['onInit']> = async (payload): Promise<void> => {
const tenant1 = await payload.create({ const tenant1 = await payload.create({
collection: 'tenants', collection: 'tenants',
data: { data: {
name: 'Tenant 1', name: 'Tenant 1',
slug: 'gold', slug: 'gold',
domain: 'gold.test', domain: 'gold.localhost',
}, },
}) })
@@ -15,7 +15,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
data: { data: {
name: 'Tenant 2', name: 'Tenant 2',
slug: 'silver', slug: 'silver',
domain: 'silver.test', domain: 'silver.localhost',
}, },
}) })
@@ -24,7 +24,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
data: { data: {
name: 'Tenant 3', name: 'Tenant 3',
slug: 'bronze', slug: 'bronze',
domain: 'bronze.test', domain: 'bronze.localhost',
}, },
}) })

View 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
}