feat: adds multi-tenant plugin (#10447)

### Multi Tenant Plugin
This PR adds a `@payloadcms/plugin-multi-tenant` package. The goal is to
consolidate a source of truth for multi-tenancy. Currently we are
maintaining different implementations for clients, users in discord and
our examples repo. When updates or new paradigms arise we need to
communicate this with everyone and update code examples which is hard to
maintain.

### What does it do?
- adds a tenant selector to the sidebar, above the nav links
- adds a hidden tenant field to every collection that you specify
- adds an array field to your users collection, allowing you to assign
users to tenants
- by default combines the access control (to enabled collections) that
you define, with access control based on the tenants assigned to user on
the request
- by default adds a baseListFilter that filters the documents shown in
the list view with the selected tenant in the admin panel

### What does it not do?
- it does not implement multi-tenancy for your frontend. You will need
to query data for specific tenants to build your website/application
- it does not add a tenants collection, you **NEED** to add a tenants
collection, where you can define what types of fields you would like on
it

### The plugin config

Most of the options listed below are _optional_, but it is easier to
just lay out all of the configuration options.

**TS Type**
```ts
type MultiTenantPluginConfig<ConfigTypes = unknown> = {
  /**
   * After a tenant is deleted, the plugin will attempt to clean up related documents
   * - removing documents with the tenant ID
   * - removing the tenant from users
   *
   * @default true
   */
  cleanupAfterTenantDelete?: boolean
  /**
   * Automatically
   */
  collections: {
    [key in CollectionSlug]?: {
      /**
       * Set to `true` if you want the collection to behave as a global
       *
       * @default false
       */
      isGlobal?: boolean
      /**
       * Set to `false` if you want to manually apply the baseListFilter
       *
       * @default true
       */
      useBaseListFilter?: boolean
      /**
       * Set to `false` if you want to handle collection access manually without the multi-tenant constraints applied
       *
       * @default true
       */
      useTenantAccess?: boolean
    }
  }
  /**
   * Enables debug mode
   * - Makes the tenant field visible in the admin UI within applicable collections
   *
   * @default false
   */
  debug?: boolean
  /**
   * Enables the multi-tenant plugin
   *
   * @default true
   */
  enabled?: boolean
  /**
   * Field configuration for the field added to all tenant enabled collections
   */
  tenantField?: {
    access?: RelationshipField['access']
    /**
     * The name of the field added to all tenant enabled collections
     *
     * @default 'tenant'
     */
    name?: string
  }
  /**
   * Field configuration for the field added to the users collection
   *
   * If `includeDefaultField` is `false`, you must include the field on your users collection manually
   * This is useful if you want to customize the field or place the field in a specific location
   */
  tenantsArrayField?:
    | {
        /**
         * Access configuration for the array field
         */
        arrayFieldAccess?: ArrayField['access']
        /**
         * When `includeDefaultField` is `true`, the field will be added to the users collection automatically
         */
        includeDefaultField?: true
        /**
         * Additional fields to include on the tenants array field
         */
        rowFields?: Field[]
        /**
         * Access configuration for the tenant field
         */
        tenantFieldAccess?: RelationshipField['access']
      }
    | {
        arrayFieldAccess?: never
        /**
         * When `includeDefaultField` is `false`, you must include the field on your users collection manually
         */
        includeDefaultField?: false
        rowFields?: never
        tenantFieldAccess?: never
      }
  /**
   * The slug for the tenant collection
   *
   * @default 'tenants'
   */
  tenantsSlug?: string
  /**
   * Function that determines if a user has access to _all_ tenants
   *
   * Useful for super-admin type users
   */
  userHasAccessToAllTenants?: (
    user: ConfigTypes extends { user: User } ? ConfigTypes['user'] : User,
  ) => boolean
}
```

**Example usage**
```ts
import type { Config } from './payload-types'
import { buildConfig } from 'payload'

export default buildConfig({
  plugins: [
    multiTenantPlugin<Config>({
      collections: {
        pages: {},
      },
      userHasAccessToAllTenants: (user) => isSuperAdmin(user),
    }),
  ],
})
```


### How to configure Collections as Globals for multi-tenant

When using multi-tenant, globals need to actually be configured as
collections so the content can be specific per tenant.
To do that, you can mark a collection with `isGlobal` and it will behave
like a global and users will not see the list view.

```ts
multiTenantPlugin({
  collections: {
    navigation: {
      isGlobal: true,
    },
  },
})
```
This commit is contained in:
Jarrod Flesch
2025-01-15 14:47:46 -05:00
committed by GitHub
parent 592f02b3bf
commit 813e70be1f
112 changed files with 3460 additions and 910 deletions

View File

@@ -40,9 +40,13 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
**Domain-based Tenant Setting**:
This example also supports domain-based tenant selection, where tenants can be associated with specific domains. If a tenant is associated with a domain (e.g., `abc.localhost.com: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.com: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.
By default, this functionality is commented out in the code but can be enabled easily. See the `setCookieBasedOnDomain` hook in the `Users` collection for more details.
The seed script seeds 3 tenants, for the domain portion of the example to function properly you will need to add the following entries to your systems `/etc/hosts` file:
- gold.localhost.com:3000
- silver.localhost.com:3000
- bronze.localhost.com:3000
- #### Pages

View File

@@ -3,6 +3,20 @@ import { withPayload } from '@payloadcms/next/withPayload'
/** @type {import('next').NextConfig} */
const nextConfig = {
// Your Next.js config here
async rewrites() {
return [
{
source: '/((?!admin|api))tenant-domains/:path*',
destination: '/tenant-domains/:tenant/:path*',
has: [
{
type: 'host',
value: '(?<tenant>.*)',
},
],
},
]
},
}
export default withPayload(nextConfig)

View File

@@ -18,6 +18,7 @@
"dependencies": {
"@payloadcms/db-mongodb": "latest",
"@payloadcms/next": "latest",
"@payloadcms/plugin-multi-tenant": "file:payloadcms-plugin-multi-tenant-3.15.1.tgz",
"@payloadcms/richtext-lexical": "latest",
"@payloadcms/ui": "latest",
"cross-env": "^7.0.3",

View File

@@ -4,26 +4,25 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@types/react': npm:types-react@19.0.0-rc.1
'@types/react-dom': npm:types-react-dom@19.0.0-rc.1
importers:
.:
dependencies:
'@payloadcms/db-mongodb':
specifier: latest
version: 3.0.2(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))
version: 3.0.2(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))
'@payloadcms/next':
specifier: latest
version: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)(typescript@5.5.2)
version: 3.0.2(@types/react@19.0.1)(graphql@16.9.0)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
'@payloadcms/plugin-multi-tenant':
specifier: file:payloadcms-plugin-multi-tenant-3.15.1.tgz
version: file:payloadcms-plugin-multi-tenant-3.15.1.tgz(@payloadcms/ui@3.0.2(@types/react@19.0.1)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))
'@payloadcms/richtext-lexical':
specifier: latest
version: 3.0.2(zatxq7p6nyuk4dq5j2z7zpivdq)
version: 3.0.2(6qerb26rs4bgoavayjhmleobf4)
'@payloadcms/ui':
specifier: latest
version: 3.0.2(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)(typescript@5.5.2)
version: 3.0.2(@types/react@19.0.1)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
cross-env:
specifier: ^7.0.3
version: 7.0.3
@@ -35,35 +34,35 @@ importers:
version: 16.9.0
next:
specifier: ^15.0.0
version: 15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4)
version: 15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
payload:
specifier: latest
version: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2)
version: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
qs-esm:
specifier: 7.0.2
version: 7.0.2
react:
specifier: 19.0.0-rc-65a56d0e-20241020
version: 19.0.0-rc-65a56d0e-20241020
specifier: 19.0.0
version: 19.0.0
react-dom:
specifier: 19.0.0-rc-65a56d0e-20241020
version: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
specifier: 19.0.0
version: 19.0.0(react@19.0.0)
sharp:
specifier: 0.32.6
version: 0.32.6
devDependencies:
'@payloadcms/graphql':
specifier: latest
version: 3.0.2(graphql@16.9.0)(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(typescript@5.5.2)
version: 3.0.2(graphql@16.9.0)(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(typescript@5.5.2)
'@swc/core':
specifier: ^1.6.13
version: 1.9.2(@swc/helpers@0.5.13)
'@types/react':
specifier: npm:types-react@19.0.0-rc.1
version: types-react@19.0.0-rc.1
specifier: 19.0.1
version: 19.0.1
'@types/react-dom':
specifier: npm:types-react-dom@19.0.0-rc.1
version: types-react-dom@19.0.0-rc.1
specifier: 19.0.1
version: 19.0.1
eslint:
specifier: ^8.57.0
version: 8.57.1
@@ -132,8 +131,8 @@ packages:
'@dnd-kit/core@6.0.8':
resolution: {integrity: sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==}
peerDependencies:
react: ^18.2.0
react-dom: ^18.2.0
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@dnd-kit/sortable@7.0.2':
resolution: {integrity: sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==}
@@ -584,8 +583,8 @@ packages:
'@lexical/react@0.20.0':
resolution: {integrity: sha512-5QbN5AFtZ9efXxU/M01ADhUZgthR0e8WKi5K/w5EPpWtYFDPQnUte3rKUjYJ7uwG1iwcvaCpuMbxJjHQ+i6pDQ==}
peerDependencies:
react: '>=17.x'
react-dom: '>=17.x'
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020
'@lexical/rich-text@0.20.0':
resolution: {integrity: sha512-BR1pACdMA+Ymef0f5EN1y+9yP8w7S+9MgmBP1yjr3w4KdqRnfSaGWyxwcHU8eA+zu16QfivpB6501VJ90YeuXw==}
@@ -712,6 +711,14 @@ packages:
next: ^15.0.0
payload: 3.0.2
'@payloadcms/plugin-multi-tenant@file:payloadcms-plugin-multi-tenant-3.15.1.tgz':
resolution: {integrity: sha512-bRuvmbrCLQr9ZL1jL4KLz+Zuv6vC4WTGKXcFkhdZoFY5tP80t78WUsxJP71FM1d1U+udJGvEA5K2bMJc9tQWOA==, tarball: file:payloadcms-plugin-multi-tenant-3.15.1.tgz}
version: 3.15.1
peerDependencies:
'@payloadcms/ui': 3.15.1
next: ^15.0.3
payload: 3.15.1
'@payloadcms/richtext-lexical@3.0.2':
resolution: {integrity: sha512-NHxnIi8qIa5ug3JQ/oX4je+94FLGhofmP02RL7BoyknNIw8Jv0zZY4NJCgrUBpmRw4CVxCc+dikMY+5243B9vA==}
engines: {node: ^18.20.2 || >=20.9.0}
@@ -871,9 +878,15 @@ packages:
'@types/parse-json@4.0.2':
resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==}
'@types/react-dom@19.0.1':
resolution: {integrity: sha512-hljHij7MpWPKF6u5vojuyfV0YA4YURsQG7KT6SzV0Zs2BXAtgdTxG6A229Ub/xiWV4w/7JL8fi6aAyjshH4meA==}
'@types/react-transition-group@4.4.11':
resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==}
'@types/react@19.0.1':
resolution: {integrity: sha512-YW6614BDhqbpR5KtUYzTA+zlA7nayzJRA9ljz9CQoxthR0sDisYZLuvSMsil36t4EH/uAt8T52Xb4sVw17G+SQ==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -2399,10 +2412,10 @@ packages:
react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
react-dom@19.0.0-rc-65a56d0e-20241020:
resolution: {integrity: sha512-OrsgAX3LQ6JtdBJayK4nG1Hj5JebzWyhKSsrP/bmkeFxulb0nG2LaPloJ6kBkAxtgjiwRyGUciJ4+Qu64gy/KA==}
react-dom@19.0.0:
resolution: {integrity: sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==}
peerDependencies:
react: 19.0.0-rc-65a56d0e-20241020
react: ^19.0.0
react-error-boundary@3.1.4:
resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==}
@@ -2441,8 +2454,8 @@ packages:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react@19.0.0-rc-65a56d0e-20241020:
resolution: {integrity: sha512-rZqpfd9PP/A97j9L1MR6fvWSMgs3khgIyLd0E+gYoCcLrxXndj+ySPRVlDPDC3+f7rm8efHNL4B6HeapqU6gzw==}
react@19.0.0:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
@@ -2525,8 +2538,8 @@ packages:
scheduler@0.0.0-experimental-3edc000d-20240926:
resolution: {integrity: sha512-360BMNajOhMyrirau0pzWVgeakvrfjbfdqHnX2K+tSGTmn6tBN+6K5NhhaebqeXXWyCU3rl5FApjgF2GN0W5JA==}
scheduler@0.25.0-rc-65a56d0e-20241020:
resolution: {integrity: sha512-HxWcXSy0sNnf+TKRkMwyVD1z19AAVQ4gUub8m7VxJUUfSu3J4lr1T+AagohKEypiW5dbQhJuCtAumPY6z9RQ1g==}
scheduler@0.25.0:
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
scmp@2.1.0:
resolution: {integrity: sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==}
@@ -2795,12 +2808,6 @@ packages:
resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==}
engines: {node: '>= 0.4'}
types-react-dom@19.0.0-rc.1:
resolution: {integrity: sha512-VSLZJl8VXCD0fAWp7DUTFUDCcZ8DVXOQmjhJMD03odgeFmu14ZQJHCXeETm3BEAhJqfgJaFkLnGkQv88sRx0fQ==}
types-react@19.0.0-rc.1:
resolution: {integrity: sha512-RshndUfqTW6K3STLPis8BtAYCGOkMbtvYsi90gmVNDZBXUyUc5juf2PE9LfS/JmOlUIRO8cWTS/1MTnmhjDqyQ==}
typescript@5.5.2:
resolution: {integrity: sha512-NcRtPEOsPFFWjobJEtfihkLCZCXZt/os3zf8nTxjVH3RvTSxjrCamJpbExGvYOF+tFHc3pA65qpdwPbzjohhew==}
engines: {node: '>=14.17'}
@@ -2993,29 +3000,29 @@ snapshots:
'@babel/helper-string-parser': 7.25.9
'@babel/helper-validator-identifier': 7.25.9
'@dnd-kit/accessibility@3.1.0(react@19.0.0-rc-65a56d0e-20241020)':
'@dnd-kit/accessibility@3.1.0(react@19.0.0)':
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
tslib: 2.8.1
'@dnd-kit/core@6.0.8(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@dnd-kit/core@6.0.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@dnd-kit/accessibility': 3.1.0(react@19.0.0-rc-65a56d0e-20241020)
'@dnd-kit/utilities': 3.2.2(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
'@dnd-kit/accessibility': 3.1.0(react@19.0.0)
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
tslib: 2.8.1
'@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.0.8(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@dnd-kit/sortable@7.0.2(@dnd-kit/core@6.0.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)':
dependencies:
'@dnd-kit/core': 6.0.8(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@dnd-kit/utilities': 3.2.2(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0-rc-65a56d0e-20241020
'@dnd-kit/core': 6.0.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@dnd-kit/utilities': 3.2.2(react@19.0.0)
react: 19.0.0
tslib: 2.8.1
'@dnd-kit/utilities@3.2.2(react@19.0.0-rc-65a56d0e-20241020)':
'@dnd-kit/utilities@3.2.2(react@19.0.0)':
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
tslib: 2.8.1
'@emnapi/runtime@1.3.1':
@@ -3061,19 +3068,19 @@ snapshots:
'@emotion/memoize@0.9.0': {}
'@emotion/react@11.13.5(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)':
'@emotion/react@11.13.5(@types/react@19.0.1)(react@19.0.0)':
dependencies:
'@babel/runtime': 7.26.0
'@emotion/babel-plugin': 11.13.5
'@emotion/cache': 11.13.5
'@emotion/serialize': 1.3.3
'@emotion/use-insertion-effect-with-fallbacks': 1.1.0(react@19.0.0-rc-65a56d0e-20241020)
'@emotion/use-insertion-effect-with-fallbacks': 1.1.0(react@19.0.0)
'@emotion/utils': 1.4.2
'@emotion/weak-memoize': 0.4.0
hoist-non-react-statics: 3.3.2
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
'@types/react': 19.0.1
transitivePeerDependencies:
- supports-color
@@ -3089,9 +3096,9 @@ snapshots:
'@emotion/unitless@0.10.0': {}
'@emotion/use-insertion-effect-with-fallbacks@1.1.0(react@19.0.0-rc-65a56d0e-20241020)':
'@emotion/use-insertion-effect-with-fallbacks@1.1.0(react@19.0.0)':
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
'@emotion/utils@1.4.2': {}
@@ -3192,23 +3199,23 @@ snapshots:
'@eslint/js@8.57.1': {}
'@faceless-ui/modal@3.0.0-beta.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@faceless-ui/modal@3.0.0-beta.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
body-scroll-lock: 4.0.0-beta.0
focus-trap: 7.5.4
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-transition-group: 4.4.5(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@faceless-ui/scroll-info@2.0.0-beta.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@faceless-ui/scroll-info@2.0.0-beta.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@faceless-ui/window-info@3.0.0-beta.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@faceless-ui/window-info@3.0.0-beta.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@floating-ui/core@1.6.8':
dependencies:
@@ -3219,18 +3226,18 @@ snapshots:
'@floating-ui/core': 1.6.8
'@floating-ui/utils': 0.2.8
'@floating-ui/react-dom@2.1.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@floating-ui/react-dom@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/dom': 1.6.12
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@floating-ui/react@0.26.28(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@floating-ui/react@0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@floating-ui/react-dom': 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@floating-ui/utils': 0.2.8
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
tabbable: 6.2.0
'@floating-ui/utils@0.2.8': {}
@@ -3355,7 +3362,7 @@ snapshots:
lexical: 0.20.0
prismjs: 1.29.0
'@lexical/devtools-core@0.20.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@lexical/devtools-core@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@lexical/html': 0.20.0
'@lexical/link': 0.20.0
@@ -3363,8 +3370,8 @@ snapshots:
'@lexical/table': 0.20.0
'@lexical/utils': 0.20.0
lexical: 0.20.0
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@lexical/dragon@0.20.0':
dependencies:
@@ -3430,11 +3437,11 @@ snapshots:
'@lexical/utils': 0.20.0
lexical: 0.20.0
'@lexical/react@0.20.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(yjs@13.6.20)':
'@lexical/react@0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(yjs@13.6.20)':
dependencies:
'@lexical/clipboard': 0.20.0
'@lexical/code': 0.20.0
'@lexical/devtools-core': 0.20.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@lexical/devtools-core': 0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@lexical/dragon': 0.20.0
'@lexical/hashtag': 0.20.0
'@lexical/history': 0.20.0
@@ -3451,9 +3458,9 @@ snapshots:
'@lexical/utils': 0.20.0
'@lexical/yjs': 0.20.0(yjs@13.6.20)
lexical: 0.20.0
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-error-boundary: 3.1.4(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-error-boundary: 3.1.4(react@19.0.0)
transitivePeerDependencies:
- yjs
@@ -3497,12 +3504,12 @@ snapshots:
monaco-editor: 0.52.0
state-local: 1.0.7
'@monaco-editor/react@4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)':
'@monaco-editor/react@4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@monaco-editor/loader': 1.4.0(monaco-editor@0.52.0)
monaco-editor: 0.52.0
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@mongodb-js/saslprep@1.1.9':
dependencies:
@@ -3552,13 +3559,13 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@payloadcms/db-mongodb@3.0.2(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))':
'@payloadcms/db-mongodb@3.0.2(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))':
dependencies:
http-status: 1.6.2
mongoose: 8.8.1
mongoose-aggregate-paginate-v2: 1.1.2
mongoose-paginate-v2: 1.8.5
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2)
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
prompts: 2.4.2
uuid: 10.0.0
transitivePeerDependencies:
@@ -3571,36 +3578,36 @@ snapshots:
- socks
- supports-color
'@payloadcms/graphql@3.0.2(graphql@16.9.0)(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(typescript@5.5.2)':
'@payloadcms/graphql@3.0.2(graphql@16.9.0)(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(typescript@5.5.2)':
dependencies:
graphql: 16.9.0
graphql-scalars: 1.22.2(graphql@16.9.0)
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2)
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
pluralize: 8.0.0
ts-essentials: 10.0.3(typescript@5.5.2)
tsx: 4.19.2
transitivePeerDependencies:
- typescript
'@payloadcms/next@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)(typescript@5.5.2)':
'@payloadcms/next@3.0.2(@types/react@19.0.1)(graphql@16.9.0)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)':
dependencies:
'@dnd-kit/core': 6.0.8(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@payloadcms/graphql': 3.0.2(graphql@16.9.0)(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(typescript@5.5.2)
'@dnd-kit/core': 6.0.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@payloadcms/graphql': 3.0.2(graphql@16.9.0)(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(typescript@5.5.2)
'@payloadcms/translations': 3.0.2
'@payloadcms/ui': 3.0.2(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)(typescript@5.5.2)
'@payloadcms/ui': 3.0.2(@types/react@19.0.1)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
busboy: 1.6.0
file-type: 19.3.0
graphql: 16.9.0
graphql-http: 1.22.3(graphql@16.9.0)
graphql-playground-html: 1.6.30
http-status: 1.6.2
next: 15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4)
next: 15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
path-to-regexp: 6.3.0
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2)
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
qs-esm: 7.0.2
react-diff-viewer-continued: 3.2.6(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
react-diff-viewer-continued: 3.2.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
sass: 1.77.4
sonner: 1.7.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
sonner: 1.7.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
uuid: 10.0.0
transitivePeerDependencies:
- '@types/react'
@@ -3610,22 +3617,28 @@ snapshots:
- supports-color
- typescript
'@payloadcms/richtext-lexical@3.0.2(zatxq7p6nyuk4dq5j2z7zpivdq)':
'@payloadcms/plugin-multi-tenant@file:payloadcms-plugin-multi-tenant-3.15.1.tgz(@payloadcms/ui@3.0.2(@types/react@19.0.1)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))':
dependencies:
'@faceless-ui/modal': 3.0.0-beta.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@faceless-ui/scroll-info': 2.0.0-beta.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@payloadcms/ui': 3.0.2(@types/react@19.0.1)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
next: 15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
'@payloadcms/richtext-lexical@3.0.2(6qerb26rs4bgoavayjhmleobf4)':
dependencies:
'@faceless-ui/modal': 3.0.0-beta.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@faceless-ui/scroll-info': 2.0.0-beta.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@lexical/headless': 0.20.0
'@lexical/link': 0.20.0
'@lexical/list': 0.20.0
'@lexical/mark': 0.20.0
'@lexical/react': 0.20.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(yjs@13.6.20)
'@lexical/react': 0.20.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(yjs@13.6.20)
'@lexical/rich-text': 0.20.0
'@lexical/selection': 0.20.0
'@lexical/table': 0.20.0
'@lexical/utils': 0.20.0
'@payloadcms/next': 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)(typescript@5.5.2)
'@payloadcms/next': 3.0.2(@types/react@19.0.1)(graphql@16.9.0)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
'@payloadcms/translations': 3.0.2
'@payloadcms/ui': 3.0.2(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)(typescript@5.5.2)
'@payloadcms/ui': 3.0.2(@types/react@19.0.1)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
'@types/uuid': 10.0.0
acorn: 8.12.1
bson-objectid: 2.0.4
@@ -3635,10 +3648,10 @@ snapshots:
mdast-util-from-markdown: 2.0.2
mdast-util-mdx-jsx: 3.1.3
micromark-extension-mdx-jsx: 3.0.1
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2)
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-error-boundary: 4.0.13(react@19.0.0-rc-65a56d0e-20241020)
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-error-boundary: 4.0.13(react@19.0.0)
ts-essentials: 10.0.3(typescript@5.5.2)
uuid: 10.0.0
transitivePeerDependencies:
@@ -3652,34 +3665,34 @@ snapshots:
dependencies:
date-fns: 4.1.0
'@payloadcms/ui@3.0.2(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2))(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)(typescript@5.5.2)':
'@payloadcms/ui@3.0.2(@types/react@19.0.1)(monaco-editor@0.52.0)(next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4))(payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)':
dependencies:
'@dnd-kit/core': 6.0.8(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@dnd-kit/sortable': 7.0.2(@dnd-kit/core@6.0.8(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@faceless-ui/modal': 3.0.0-beta.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@faceless-ui/scroll-info': 2.0.0-beta.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@faceless-ui/window-info': 3.0.0-beta.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@monaco-editor/react': 4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@dnd-kit/core': 6.0.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@dnd-kit/sortable': 7.0.2(@dnd-kit/core@6.0.8(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
'@faceless-ui/modal': 3.0.0-beta.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@faceless-ui/scroll-info': 2.0.0-beta.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@faceless-ui/window-info': 3.0.0-beta.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@monaco-editor/react': 4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@payloadcms/translations': 3.0.2
body-scroll-lock: 4.0.0-beta.0
bson-objectid: 2.0.4
date-fns: 4.1.0
dequal: 2.0.3
md5: 2.3.0
next: 15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4)
next: 15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
object-to-formdata: 4.5.1
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2)
payload: 3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2)
qs-esm: 7.0.2
react: 19.0.0-rc-65a56d0e-20241020
react-animate-height: 2.1.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
react-datepicker: 6.9.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-image-crop: 10.1.8(react@19.0.0-rc-65a56d0e-20241020)
react-select: 5.8.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)
react: 19.0.0
react-animate-height: 2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-datepicker: 6.9.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-dom: 19.0.0(react@19.0.0)
react-image-crop: 10.1.8(react@19.0.0)
react-select: 5.8.0(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
scheduler: 0.0.0-experimental-3edc000d-20240926
sonner: 1.7.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
sonner: 1.7.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
ts-essentials: 10.0.3(typescript@5.5.2)
use-context-selector: 2.0.0(react@19.0.0-rc-65a56d0e-20241020)(scheduler@0.0.0-experimental-3edc000d-20240926)
use-context-selector: 2.0.0(react@19.0.0)(scheduler@0.0.0-experimental-3edc000d-20240926)
uuid: 10.0.0
transitivePeerDependencies:
- '@types/react'
@@ -3790,9 +3803,17 @@ snapshots:
'@types/parse-json@4.0.2': {}
'@types/react-dom@19.0.1':
dependencies:
'@types/react': 19.0.1
'@types/react-transition-group@4.4.11':
dependencies:
'@types/react': types-react@19.0.0-rc.1
'@types/react': 19.0.1
'@types/react@19.0.1':
dependencies:
csstype: 3.1.3
'@types/unist@2.0.11': {}
@@ -5368,7 +5389,7 @@ snapshots:
natural-compare@1.4.0: {}
next@15.0.3(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(sass@1.77.4):
next@15.0.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4):
dependencies:
'@next/env': 15.0.3
'@swc/counter': 0.1.3
@@ -5376,9 +5397,9 @@ snapshots:
busboy: 1.6.0
caniuse-lite: 1.0.30001683
postcss: 8.4.31
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
styled-jsx: 5.1.6(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.0.3
'@next/swc-darwin-x64': 15.0.3
@@ -5499,9 +5520,9 @@ snapshots:
path-type@4.0.0: {}
payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(typescript@5.5.2):
payload@3.0.2(graphql@16.9.0)(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(typescript@5.5.2):
dependencies:
'@monaco-editor/react': 4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@monaco-editor/react': 4.6.0(monaco-editor@0.52.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@next/env': 15.0.3
'@payloadcms/translations': 3.0.2
'@types/busboy': 1.5.4
@@ -5651,88 +5672,88 @@ snapshots:
minimist: 1.2.8
strip-json-comments: 2.0.1
react-animate-height@2.1.2(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
react-animate-height@2.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
classnames: 2.5.1
prop-types: 15.8.1
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-datepicker@6.9.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
react-datepicker@6.9.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
'@floating-ui/react': 0.26.28(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
clsx: 2.1.1
date-fns: 3.6.0
prop-types: 15.8.1
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-onclickoutside: 6.13.1(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-onclickoutside: 6.13.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react-diff-viewer-continued@3.2.6(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
react-diff-viewer-continued@3.2.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@emotion/css': 11.13.5
classnames: 2.5.1
diff: 5.2.0
memoize-one: 6.0.0
prop-types: 15.8.1
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
transitivePeerDependencies:
- supports-color
react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020):
react-dom@19.0.0(react@19.0.0):
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
scheduler: 0.25.0-rc-65a56d0e-20241020
react: 19.0.0
scheduler: 0.25.0
react-error-boundary@3.1.4(react@19.0.0-rc-65a56d0e-20241020):
react-error-boundary@3.1.4(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.0
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
react-error-boundary@4.0.13(react@19.0.0-rc-65a56d0e-20241020):
react-error-boundary@4.0.13(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.0
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
react-image-crop@10.1.8(react@19.0.0-rc-65a56d0e-20241020):
react-image-crop@10.1.8(react@19.0.0):
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
react-is@16.13.1: {}
react-onclickoutside@6.13.1(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
react-onclickoutside@6.13.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-select@5.8.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1):
react-select@5.8.0(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.0
'@emotion/cache': 11.13.5
'@emotion/react': 11.13.5(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)
'@emotion/react': 11.13.5(@types/react@19.0.1)(react@19.0.0)
'@floating-ui/dom': 1.6.12
'@types/react-transition-group': 4.4.11
memoize-one: 6.0.0
prop-types: 15.8.1
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react-transition-group: 4.4.5(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020)
use-isomorphic-layout-effect: 1.1.2(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react-transition-group: 4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
use-isomorphic-layout-effect: 1.1.2(@types/react@19.0.1)(react@19.0.0)
transitivePeerDependencies:
- '@types/react'
- supports-color
react-transition-group@4.4.5(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
react-transition-group@4.4.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.26.0
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
react@19.0.0-rc-65a56d0e-20241020: {}
react@19.0.0: {}
readable-stream@3.6.2:
dependencies:
@@ -5822,7 +5843,7 @@ snapshots:
scheduler@0.0.0-experimental-3edc000d-20240926: {}
scheduler@0.25.0-rc-65a56d0e-20241020: {}
scheduler@0.25.0: {}
scmp@2.1.0: {}
@@ -5921,10 +5942,10 @@ snapshots:
dependencies:
atomic-sleep: 1.0.0
sonner@1.7.0(react-dom@19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020))(react@19.0.0-rc-65a56d0e-20241020):
sonner@1.7.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react-dom: 19.0.0-rc-65a56d0e-20241020(react@19.0.0-rc-65a56d0e-20241020)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
source-map-js@1.2.1: {}
@@ -6017,10 +6038,10 @@ snapshots:
'@tokenizer/token': 0.3.0
peek-readable: 5.3.1
styled-jsx@5.1.6(react@19.0.0-rc-65a56d0e-20241020):
styled-jsx@5.1.6(react@19.0.0):
dependencies:
client-only: 0.0.1
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
stylis@4.2.0: {}
@@ -6159,14 +6180,6 @@ snapshots:
is-typed-array: 1.1.13
possible-typed-array-names: 1.0.0
types-react-dom@19.0.0-rc.1:
dependencies:
'@types/react': types-react@19.0.0-rc.1
types-react@19.0.0-rc.1:
dependencies:
csstype: 3.1.3
typescript@5.5.2: {}
uint8array-extras@1.4.0: {}
@@ -6207,16 +6220,16 @@ snapshots:
dependencies:
punycode: 2.3.1
use-context-selector@2.0.0(react@19.0.0-rc-65a56d0e-20241020)(scheduler@0.0.0-experimental-3edc000d-20240926):
use-context-selector@2.0.0(react@19.0.0)(scheduler@0.0.0-experimental-3edc000d-20240926):
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
scheduler: 0.0.0-experimental-3edc000d-20240926
use-isomorphic-layout-effect@1.1.2(react@19.0.0-rc-65a56d0e-20241020)(types-react@19.0.0-rc.1):
use-isomorphic-layout-effect@1.1.2(@types/react@19.0.1)(react@19.0.0):
dependencies:
react: 19.0.0-rc-65a56d0e-20241020
react: 19.0.0
optionalDependencies:
'@types/react': types-react@19.0.0-rc.1
'@types/react': 19.0.1
utf8-byte-length@1.0.5: {}

View File

@@ -1,8 +1,10 @@
import type { Access } from 'payload'
import { User } from '../payload-types'
export const isSuperAdmin: Access = ({ req }) => {
if (!req?.user) {
return false
}
return Boolean(req.user.roles?.includes('super-admin'))
export const isSuperAdminAccess: Access = ({ req }): boolean => {
return isSuperAdmin(req.user)
}
export const isSuperAdmin = (user: User | null): boolean => {
return Boolean(user?.roles?.includes('super-admin'))
}

View File

@@ -0,0 +1,30 @@
export default async ({ params: paramsPromise }: { params: Promise<{ slug: string[] }> }) => {
return (
<div>
<h1>Multi-Tenant Example</h1>
<p>
This multi-tenant example allows you to explore multi-tenancy with domains and with slugs.
</p>
<h2>Domains</h2>
<p>When you visit a tenant by domain, the domain is used to determine the tenant.</p>
<p>
For example, visiting{' '}
<a href="http://gold.localhost.com:3000/tenant-domains/login">
http://gold.localhost.com:3000/tenant-domains/login
</a>{' '}
will show the tenant with the domain "gold.localhost.com".
</p>
<h2>Slugs</h2>
<p>When you visit a tenant by slug, the slug is used to determine the tenant.</p>
<p>
For example, visiting{' '}
<a href="http://localhost:3000/tenant-slugs/silver/login">
http://localhost:3000/tenant-slugs/silver/login
</a>{' '}
will show the tenant with the slug "silver".
</p>
</div>
)
}

View File

@@ -0,0 +1,111 @@
import type { Where } from 'payload'
import configPromise from '@payload-config'
import { headers as getHeaders } from 'next/headers'
import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import React from 'react'
import { RenderPage } from '../../../../components/RenderPage'
// eslint-disable-next-line no-restricted-exports
export default async function Page({
params: paramsPromise,
}: {
params: Promise<{ slug?: string[]; tenant: string }>
}) {
const params = await paramsPromise
let slug = undefined
if (params?.slug) {
// remove the domain route param
params.slug.splice(0, 1)
slug = params.slug
}
const headers = await getHeaders()
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers })
try {
const tenantsQuery = await payload.find({
collection: 'tenants',
overrideAccess: false,
user,
where: {
domain: {
equals: params.tenant,
},
},
})
// If no tenant is found, the user does not have access
// Show the login view
if (tenantsQuery.docs.length === 0) {
redirect(
`/tenant-domains/login?redirect=${encodeURIComponent(
`/tenant-domains${slug ? `/${slug.join('/')}` : ''}`,
)}`,
)
}
} catch (e) {
// If the query fails, it means the user did not have access to query on the domain field
// Show the login view
redirect(
`/tenant-domains/login?redirect=${encodeURIComponent(
`/tenant-domains${slug ? `/${slug.join('/')}` : ''}`,
)}`,
)
}
const slugConstraint: Where = slug
? {
slug: {
equals: slug.join('/'),
},
}
: {
or: [
{
slug: {
equals: '',
},
},
{
slug: {
equals: 'home',
},
},
{
slug: {
exists: false,
},
},
],
}
const pageQuery = await payload.find({
collection: 'pages',
overrideAccess: false,
user,
where: {
and: [
{
'tenant.domain': {
equals: params.tenant,
},
},
slugConstraint,
],
},
})
const pageData = pageQuery.docs?.[0]
// The page with the provided slug could not be found
if (!pageData) {
return notFound()
}
// The page was found, render the page with data
return <RenderPage data={pageData} />
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import { Login } from '../../../../components/Login/client.page'
type RouteParams = {
tenant: string
}
// eslint-disable-next-line no-restricted-exports
export default async function Page({ params: paramsPromise }: { params: Promise<RouteParams> }) {
const params = await paramsPromise
return <Login tenantDomain={params.tenant} />
}

View File

@@ -6,7 +6,7 @@ import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import React from 'react'
import { RenderPage } from '../../../components/RenderPage'
import { RenderPage } from '../../../../components/RenderPage'
// eslint-disable-next-line no-restricted-exports
export default async function Page({
@@ -20,25 +20,34 @@ export default async function Page({
const payload = await getPayload({ config: configPromise })
const { user } = await payload.auth({ headers })
const tenantsQuery = await payload.find({
collection: 'tenants',
overrideAccess: false,
user,
where: {
slug: {
equals: params.tenant,
},
},
})
const slug = params?.slug
// If no tenant is found, the user does not have access
// Show the login view
if (tenantsQuery.docs.length === 0) {
try {
const tenantsQuery = await payload.find({
collection: 'tenants',
overrideAccess: false,
user,
where: {
slug: {
equals: params.tenant,
},
},
})
// If no tenant is found, the user does not have access
// Show the login view
if (tenantsQuery.docs.length === 0) {
redirect(
`/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent(
`/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`,
)}`,
)
}
} catch (e) {
// If the query fails, it means the user did not have access to query on the slug field
// Show the login view
redirect(
`/${params.tenant}/login?redirect=${encodeURIComponent(
`/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`,
`/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent(
`/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`,
)}`,
)
}

View File

@@ -1,6 +1,6 @@
import React from 'react'
import { Login } from '../../../components/Login/client.page'
import { Login } from '../../../../components/Login/client.page'
type RouteParams = {
tenant: string

View File

@@ -0,0 +1,3 @@
import Page from './[...slug]/page'
export default Page

View File

@@ -1,9 +1,9 @@
import { TenantFieldComponent as TenantFieldComponent_0e322269e5426a9b98ca88b6faa9d3d0 } from '@/fields/TenantField/components/Field'
import { TenantSelectorRSC as TenantSelectorRSC_9d7720c4b50db35595dfefa592fabd33 } from '@/components/TenantSelector'
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { TenantSelector as TenantSelector_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
import { TenantSelectionProvider as TenantSelectionProvider_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
export const importMap = {
'@/fields/TenantField/components/Field#TenantFieldComponent':
TenantFieldComponent_0e322269e5426a9b98ca88b6faa9d3d0,
'@/components/TenantSelector#TenantSelectorRSC':
TenantSelectorRSC_9d7720c4b50db35595dfefa592fabd33,
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelector": TenantSelector_d6d5f193a167989e2ee7d14202901e62,
"@payloadcms/plugin-multi-tenant/client#TenantSelectionProvider": TenantSelectionProvider_1d0591e3cf4f332c83a86da13a0de59a
}

View File

@@ -1,21 +1,25 @@
'use client'
import type { FormEvent } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { useRouter, useSearchParams } from 'next/navigation'
import React from 'react'
import './index.scss'
const baseClass = 'loginPage'
// go to /tenant1/home
// redirects to /tenant1/login?redirect=%2Ftenant1%2Fhome
// login, uses slug to set payload-tenant cookie
type Props = {
tenantSlug: string
tenantSlug?: string
tenantDomain?: string
}
export const Login = ({ tenantSlug }: Props) => {
export const Login = ({ tenantSlug, tenantDomain }: Props) => {
const usernameRef = React.useRef<HTMLInputElement>(null)
const passwordRef = React.useRef<HTMLInputElement>(null)
const router = useRouter()
const routeParams = useParams()
const searchParams = useSearchParams()
const handleSubmit = async (e: FormEvent) => {
@@ -23,20 +27,18 @@ export const Login = ({ tenantSlug }: Props) => {
if (!usernameRef?.current?.value || !passwordRef?.current?.value) {
return
}
const actionRes = await fetch(
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/external-users/login`,
{
body: JSON.stringify({
password: passwordRef.current.value,
tenantSlug,
username: usernameRef.current.value,
}),
headers: {
'content-type': 'application/json',
},
method: 'post',
const actionRes = await fetch('/api/users/external-users/login', {
body: JSON.stringify({
password: passwordRef.current.value,
tenantSlug,
tenantDomain,
username: usernameRef.current.value,
}),
headers: {
'content-type': 'application/json',
},
)
method: 'post',
})
const json = await actionRes.json()
if (actionRes.status === 200 && json.user) {
@@ -45,7 +47,11 @@ export const Login = ({ tenantSlug }: Props) => {
router.push(redirectTo)
return
} else {
router.push(`/${routeParams.tenant}`)
if (tenantDomain) {
router.push('/tenant-domains')
} else {
router.push(`/tenant-slugs/${tenantSlug}`)
}
}
} else if (actionRes.status === 400 && json?.errors?.[0]?.message) {
window.alert(json.errors[0].message)

View File

@@ -5,7 +5,11 @@ import React from 'react'
export const RenderPage = ({ data }: { data: Page }) => {
return (
<React.Fragment>
<form action="/api/users/logout" method="post">
<button type="submit">Logout</button>
</form>
<h2>Here you can decide how you would like to render the page data!</h2>
<code>{JSON.stringify(data)}</code>
</React.Fragment>
)

View File

@@ -1,26 +0,0 @@
import type { BaseListFilter } from 'payload'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { getTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { parseCookies } from 'payload'
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 tenantAccessIDs = getTenantAccessIDs(req.user)
// if user is super admin or has access to the selected tenant
if (selectedTenant && (superAdmin || tenantAccessIDs.some((id) => id === selectedTenant))) {
// set a base filter for the list view
return {
tenant: {
equals: selectedTenant,
},
}
}
// Access control will take it from here
return null
}

View File

@@ -1,95 +0,0 @@
import type { Access } from 'payload'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const filterByTenantRead: Access = (args) => {
const req = args.req
const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(args)
const selectedTenant = cookies.get('payload-tenant')
const tenantAccessIDs = getTenantAccessIDs(req.user)
// First check for manually selected tenant from cookies
if (selectedTenant) {
// If it's a super admin,
// give them read access to only pages for that tenant
if (superAdmin) {
return {
tenant: {
equals: selectedTenant,
},
}
}
const hasTenantAccess = tenantAccessIDs.some((id) => id === selectedTenant)
// If NOT super admin,
// give them access only if they have access to tenant ID set in cookie
if (hasTenantAccess) {
return {
tenant: {
equals: selectedTenant,
},
}
}
}
// If no manually selected tenant,
// but it is a super admin, give access to all
if (superAdmin) {
return true
}
// If not super admin,
// but has access to tenants,
// give access to only their own tenants
if (tenantAccessIDs.length) {
return {
tenant: {
in: tenantAccessIDs,
},
}
}
// 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

@@ -1,60 +0,0 @@
import type { Access, Where } from 'payload'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
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 tenantAccessIDs = getTenantAccessIDs(req.user)
const publicPageConstraint: Where = {
'tenant.public': {
equals: true,
},
}
// If it's a super admin or has access to the selected tenant
if (selectedTenant && (superAdmin || tenantAccessIDs.some((id) => id === selectedTenant))) {
// filter access by selected tenant
return {
or: [
publicPageConstraint,
{
tenant: {
equals: selectedTenant,
},
},
],
}
}
// If no manually selected tenant,
// but it is a super admin, give access to all
if (superAdmin) {
return true
}
// If not super admin,
// but has access to tenants,
// give access to only their own tenants
if (tenantAccessIDs.length) {
return {
or: [
publicPageConstraint,
{
tenant: {
in: tenantAccessIDs,
},
},
],
}
}
// Allow access to public pages
return publicPageConstraint
}

View File

@@ -0,0 +1,22 @@
import { getUserTenantIDs } from '@/utilities/getUserTenantIDs'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { Access } from 'payload'
/**
* Tenant admins and super admins can will be allowed access
*/
export const superAdminOrTeanantAdminAccess: Access = ({ req }) => {
if (!req.user) {
return false
}
if (isSuperAdmin(req.user)) {
return true
}
return {
tenant: {
in: getUserTenantIDs(req.user, 'tenant-admin'),
},
}
}

View File

@@ -2,7 +2,7 @@ import type { FieldHook } from 'payload'
import { ValidationError } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation
@@ -34,7 +34,7 @@ export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, valu
})
if (findDuplicatePages.docs.length > 0 && req.user) {
const tenantIDs = getTenantAccessIDs(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) {

View File

@@ -1,21 +1,17 @@
import type { CollectionConfig } from 'payload'
import { tenantField } from '../../fields/TenantField'
import { baseListFilter } from './access/baseListFilter'
import { canMutatePage } from './access/byTenant'
import { readAccess } from './access/readAccess'
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
import { superAdminOrTeanantAdminAccess } from '@/collections/Pages/access/superAdminOrTenantAdmin'
export const Pages: CollectionConfig = {
slug: 'pages',
access: {
create: canMutatePage,
delete: canMutatePage,
read: readAccess,
update: canMutatePage,
create: superAdminOrTeanantAdminAccess,
delete: superAdminOrTeanantAdminAccess,
read: () => true,
update: superAdminOrTeanantAdminAccess,
},
admin: {
baseListFilter,
useAsTitle: 'title',
},
fields: [
@@ -32,6 +28,5 @@ export const Pages: CollectionConfig = {
},
index: true,
},
tenantField,
],
}

View File

@@ -1,53 +1,26 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
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) {
if (!args.req.user) {
return {
or: [
publicConstraint,
{
id: {
in: tenantIDs,
},
},
],
allowPublicRead: {
equals: true,
},
}
}
return publicConstraint
return true
}
export const canMutateTenant: Access = (args) => {
const req = args.req
const superAdmin = isSuperAdmin(args)
export const canMutateTenant: Access = ({ req }) => {
if (!req.user) {
return false
}
// super admins can mutate pages for any tenant
if (superAdmin) {
if (isSuperAdmin(req.user)) {
return true
}

View File

@@ -1,39 +0,0 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const tenantRead: 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
}

View File

@@ -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'),
},
}
}

View File

@@ -1,15 +1,15 @@
import type { CollectionConfig } from 'payload'
import { isSuperAdmin } from '../../access/isSuperAdmin'
import { canMutateTenant, filterByTenantRead } from './access/byTenant'
import { isSuperAdminAccess } from '@/access/isSuperAdmin'
import { updateAndDeleteAccess } from './access/updateAndDelete'
export const Tenants: CollectionConfig = {
slug: 'tenants',
access: {
create: isSuperAdmin,
delete: canMutateTenant,
read: filterByTenantRead,
update: canMutateTenant,
create: isSuperAdminAccess,
delete: updateAndDeleteAccess,
read: ({ req }) => Boolean(req.user),
update: updateAndDeleteAccess,
},
admin: {
useAsTitle: 'name',
@@ -20,23 +20,13 @@ export const Tenants: CollectionConfig = {
type: 'text',
required: true,
},
// The domains field allows you to associate one or more domains with a tenant.
// This is used to determine which tenant is associated with a specific domain,
// for example, 'abc.localhost.com' would match to 'Tenant 1'.
// Uncomment this field if you want to enable domain-based tenant handling.
// {
// name: 'domains',
// type: 'array',
// fields: [
// {
// name: 'domain',
// type: 'text',
// required: true,
// },
// ],
// index: true,
// },
{
name: 'domain',
type: 'text',
admin: {
description: 'Used for domain-based tenant handling',
},
},
{
name: 'slug',
type: 'text',
@@ -47,10 +37,11 @@ export const Tenants: CollectionConfig = {
required: true,
},
{
name: 'public',
name: 'allowPublicRead',
type: 'checkbox',
admin: {
description: 'If checked, logging in is not required.',
description:
'If checked, logging in is not required to read. Useful for building public pages.',
position: 'sidebar',
},
defaultValue: false,

View File

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

View File

@@ -1,8 +1,5 @@
import type { Access } from 'payload'
import { User } from '@/payload-types'
export const isAccessingSelf: Access = ({ id, req }) => {
if (!req?.user) {
return false
}
return req.user.id === id
export const isAccessingSelf = ({ id, user }: { user?: User; id?: string | number }): boolean => {
return user ? Boolean(user.id === id) : false
}

View File

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

View File

@@ -4,35 +4,27 @@ import type { Access, Where } from 'payload'
import { parseCookies } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
import { isAccessingSelf } from './isAccessingSelf'
export const readAccess: Access<User> = (args) => {
const { req } = args
export const readAccess: Access<User> = ({ req, id }) => {
if (!req?.user) {
return false
}
if (isAccessingSelf({ id, user: req.user })) {
return true
}
const cookies = parseCookies(req.headers)
const superAdmin = isSuperAdmin(args)
const superAdmin = isSuperAdmin(req.user)
const selectedTenant = cookies.get('payload-tenant')
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
if (selectedTenant) {
// If it's a super admin,
// give them read access to only pages for that tenant
if (superAdmin) {
return {
'tenants.tenant': {
equals: selectedTenant,
},
}
}
const tenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
const hasTenantAccess = tenantAccessIDs.some((id) => id === selectedTenant)
// If NOT super admin,
// give them access only if they have access to tenant ID set in cookie
if (hasTenantAccess) {
// 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,
@@ -45,11 +37,18 @@ export const readAccess: Access<User> = (args) => {
return true
}
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
return {
'tenants.tenant': {
in: adminTenantAccessIDs,
},
or: [
{
id: {
equals: req.user.id,
},
},
{
'tenants.tenant': {
in: adminTenantAccessIDs,
},
},
],
} as Where
}

View File

@@ -1,23 +1,31 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { isAccessingSelf } from './isAccessingSelf'
export const updateAndDeleteAccess: Access = (args) => {
const { req } = args
if (!req.user) {
export const updateAndDeleteAccess: Access = ({ req, id }) => {
const { user } = req
if (!user) {
return false
}
if (isSuperAdmin(args)) {
if (isSuperAdmin(user) || isAccessingSelf({ user, id })) {
return true
}
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
/**
* 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: adminTenantAccessIDs,
in: getUserTenantIDs(user, 'tenant-admin'),
},
}
}

View File

@@ -16,7 +16,7 @@ export const externalUsersLogin: Endpoint = {
} catch (error) {
// swallow error, data is already empty object
}
const { password, tenantSlug, username } = data
const { password, tenantSlug, tenantDomain, username } = data
if (!username || !password) {
throw new APIError('Username and Password are required for login.', 400, null, true)
@@ -25,11 +25,17 @@ export const externalUsersLogin: Endpoint = {
const fullTenant = (
await req.payload.find({
collection: 'tenants',
where: {
slug: {
equals: tenantSlug,
},
},
where: tenantDomain
? {
domain: {
equals: tenantDomain,
},
}
: {
slug: {
equals: tenantSlug,
},
},
})
).docs[0]

View File

@@ -2,7 +2,7 @@ import type { FieldHook } from 'payload'
import { ValidationError } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
// if value is unchanged, skip validation
@@ -34,7 +34,7 @@ export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req,
})
if (findDuplicateUsers.docs.length > 0 && req.user) {
const tenantIDs = getTenantAccessIDs(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) {

View File

@@ -9,8 +9,8 @@ export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, us
depth: 0,
limit: 1,
where: {
'domains.domain': {
in: [req.headers.get('host')],
domain: {
equals: req.headers.get('host'),
},
},
})

View File

@@ -5,7 +5,24 @@ import { readAccess } from './access/read'
import { updateAndDeleteAccess } from './access/updateAndDelete'
import { externalUsersLogin } from './endpoints/externalUsersLogin'
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
// import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain'
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
const defaultTenantArrayField = tenantsArrayField({
arrayFieldAccess: {},
tenantFieldAccess: {},
rowFields: [
{
name: 'roles',
type: 'select',
defaultValue: ['tenant-viewer'],
hasMany: true,
options: ['tenant-admin', 'tenant-viewer'],
required: true,
},
],
})
const Users: CollectionConfig = {
slug: 'users',
@@ -22,34 +39,19 @@ const Users: CollectionConfig = {
endpoints: [externalUsersLogin],
fields: [
{
admin: {
position: 'sidebar',
},
name: 'roles',
type: 'select',
defaultValue: ['user'],
hasMany: true,
options: ['super-admin', 'user'],
},
{
name: 'tenants',
type: 'array',
fields: [
{
name: 'tenant',
type: 'relationship',
index: true,
relationTo: 'tenants',
required: true,
saveToJWT: true,
access: {
update: ({ req }) => {
return isSuperAdmin(req.user)
},
{
name: 'roles',
type: 'select',
defaultValue: ['tenant-viewer'],
hasMany: true,
options: ['tenant-admin', 'tenant-viewer'],
required: true,
},
],
saveToJWT: true,
},
},
{
name: 'username',
@@ -59,15 +61,21 @@ const Users: CollectionConfig = {
},
index: true,
},
{
...defaultTenantArrayField,
admin: {
...(defaultTenantArrayField?.admin || {}),
position: 'sidebar',
},
},
],
// The following hook sets a cookie based on the domain a user logs in from.
// It checks the domain and matches it to a tenant in the system, then sets
// a 'payload-tenant' cookie for that tenant.
// Uncomment this if you want to enable tenant-based cookie handling by domain.
// hooks: {
// afterLogin: [setCookieBasedOnDomain],
// },
hooks: {
afterLogin: [setCookieBasedOnDomain],
},
}
export default Users

View File

@@ -1,97 +0,0 @@
'use client'
import type { Option } from '@payloadcms/ui/elements/ReactSelect'
import type { OptionObject } from 'payload'
import { getTenantAdminTenantAccessIDs } from '@/utilities/getTenantAccessIDs'
import { SelectInput, useAuth } from '@payloadcms/ui'
import * as qs from 'qs-esm'
import React from 'react'
import type { Tenant, User } from '../../payload-types'
import './index.scss'
export const TenantSelector = ({ initialCookie }: { initialCookie?: string }) => {
const { user } = useAuth<User>()
const [options, setOptions] = React.useState<OptionObject[]>([])
const isSuperAdmin = user?.roles?.includes('super-admin')
const tenantIDs =
user?.tenants?.map(({ tenant }) => {
if (tenant) {
if (typeof tenant === 'string') {
return tenant
}
return tenant.id
}
}) || []
function setCookie(name: string, value?: string) {
const expires = '; expires=Fri, 31 Dec 9999 23:59:59 GMT'
document.cookie = name + '=' + (value || '') + expires + '; path=/'
}
const handleChange = React.useCallback((option: Option | Option[]) => {
if (!option) {
setCookie('payload-tenant', undefined)
window.location.reload()
} else if ('value' in option) {
setCookie('payload-tenant', option.value as string)
window.location.reload()
}
}, [])
React.useEffect(() => {
const fetchTenants = async () => {
const adminOfTenants = getTenantAdminTenantAccessIDs(user ?? null)
const queryString = qs.stringify(
{
depth: 0,
limit: 100,
sort: 'name',
where: {
id: {
in: adminOfTenants,
},
},
},
{
addQueryPrefix: true,
},
)
const res = await fetch(`/api/tenants${queryString}`, {
credentials: 'include',
}).then((res) => res.json())
const optionsToSet = res.docs.map((doc: Tenant) => ({ label: doc.name, value: doc.id }))
if (optionsToSet.length === 1) {
setCookie('payload-tenant', optionsToSet[0].value)
}
setOptions(optionsToSet)
}
if (user) {
void fetchTenants()
}
}, [user])
if ((isSuperAdmin || tenantIDs.length > 1) && options.length > 1) {
return (
<div className="tenant-selector">
<SelectInput
label="Select a tenant"
name="setTenant"
onChange={handleChange}
options={options}
path="setTenant"
value={options.find((opt) => opt.value === initialCookie)?.value}
/>
</div>
)
}
return null
}

View File

@@ -1,3 +0,0 @@
.tenant-selector {
width: 100%;
}

View File

@@ -1,9 +0,0 @@
import { cookies as getCookies } from 'next/headers'
import React from 'react'
import { TenantSelector } from './index.client'
export const TenantSelectorRSC = async () => {
const cookies = await getCookies()
return <TenantSelector initialCookie={cookies.get('payload-tenant')?.value} />
}

View File

@@ -1,9 +0,0 @@
import type { FieldAccess } from 'payload'
import { isSuperAdmin } from '../../../access/isSuperAdmin'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const tenantFieldUpdate: FieldAccess = (args) => {
const tenantIDs = getTenantAccessIDs(args.req.user)
return Boolean(isSuperAdmin(args) || tenantIDs.length > 0)
}

View File

@@ -1,34 +0,0 @@
'use client'
import { RelationshipField, useField } from '@payloadcms/ui'
import React from 'react'
type Props = {
initialValue?: string
path: string
readOnly: boolean
}
export function TenantFieldComponentClient({ initialValue, path, readOnly }: Props) {
const { formInitializing, setValue, value } = useField({ path })
const hasSetInitialValue = React.useRef(false)
React.useEffect(() => {
if (!hasSetInitialValue.current && !formInitializing && initialValue && !value) {
setValue(initialValue)
hasSetInitialValue.current = true
}
}, [initialValue, setValue, formInitializing, value])
return (
<RelationshipField
field={{
name: path,
type: 'relationship',
label: 'Tenant',
relationTo: 'tenants',
required: true,
}}
path={path}
readOnly={readOnly}
/>
)
}

View File

@@ -1,32 +0,0 @@
import type { Payload } from 'payload'
import { cookies as getCookies, headers as getHeaders } from 'next/headers'
import React from 'react'
import { TenantFieldComponentClient } from './Field.client'
export const TenantFieldComponent: React.FC<{
path: string
payload: Payload
readOnly: boolean
}> = async (args) => {
const cookies = await getCookies()
const headers = await getHeaders()
const { user } = await args.payload.auth({ headers })
if (
user &&
((Array.isArray(user.tenants) && user.tenants.length > 1) ||
user?.roles?.includes('super-admin'))
) {
return (
<TenantFieldComponentClient
initialValue={cookies.get('payload-tenant')?.value || undefined}
path={args.path}
readOnly={args.readOnly}
/>
)
}
return null
}

View File

@@ -1,17 +0,0 @@
import type { FieldHook } from 'payload'
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
export const autofillTenant: FieldHook = ({ req, value }) => {
// If there is no value,
// and the user only has one tenant,
// return that tenant ID as the value
if (!value) {
const tenantIDs = getTenantAccessIDs(req.user)
if (tenantIDs.length === 1) {
return tenantIDs[0]
}
}
return value
}

View File

@@ -1,32 +0,0 @@
import type { Field } from 'payload'
import { isSuperAdmin } from '../../access/isSuperAdmin'
import { tenantFieldUpdate } from './access/update'
import { autofillTenant } from './hooks/autofillTenant'
export const tenantField: Field = {
name: 'tenant',
type: 'relationship',
access: {
read: () => true,
update: (args) => {
if (isSuperAdmin(args)) {
return true
}
return tenantFieldUpdate(args)
},
},
admin: {
components: {
Field: '@/fields/TenantField/components/Field#TenantFieldComponent',
},
position: 'sidebar',
},
hasMany: false,
hooks: {
beforeValidate: [autofillTenant],
},
index: true,
relationTo: 'tenants',
required: true,
}

View File

@@ -10,15 +10,12 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
},
})
// The 'domains' field is used to associate a domain with this tenant.
// Uncomment and set the domain if you want to enable domain-based tenant assignment.
const tenant1 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 1',
slug: 'tenant-1',
// domains: [{ domain: 'abc.localhost.com:3000' }],
slug: 'gold',
domain: 'gold.localhost.com',
},
})
@@ -26,8 +23,8 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
collection: 'tenants',
data: {
name: 'Tenant 2',
slug: 'tenant-2',
// domains: [{ domain: 'bbc.localhost.com:3000' }],
slug: 'silver',
domain: 'silver.localhost.com',
},
})
@@ -35,8 +32,8 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
collection: 'tenants',
data: {
name: 'Tenant 3',
slug: 'tenant-3',
// domains: [{ domain: 'cbc.localhost.com:3000' }],
slug: 'bronze',
domain: 'bronze.localhost.com',
},
})

View File

@@ -1 +0,0 @@
export default {}

View File

@@ -36,9 +36,9 @@ export interface Config {
user: User & {
collection: 'users';
};
jobs?: {
jobs: {
tasks: unknown;
workflows?: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
@@ -65,9 +65,9 @@ export interface UserAuthOperations {
*/
export interface Page {
id: string;
tenant?: (string | null) | Tenant;
title?: string | null;
slug?: string | null;
tenant: string | Tenant;
updatedAt: string;
createdAt: string;
}
@@ -78,8 +78,9 @@ export interface Page {
export interface Tenant {
id: string;
name: string;
domain?: string | null;
slug: string;
public?: boolean | null;
allowPublicRead?: boolean | null;
updatedAt: string;
createdAt: string;
}
@@ -90,6 +91,7 @@ export interface Tenant {
export interface User {
id: string;
roles?: ('super-admin' | 'user')[] | null;
username?: string | null;
tenants?:
| {
tenant: string | Tenant;
@@ -97,7 +99,6 @@ export interface User {
id?: string | null;
}[]
| null;
username?: string | null;
updatedAt: string;
createdAt: string;
email: string;
@@ -175,9 +176,9 @@ export interface PayloadMigration {
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
tenant?: T;
title?: T;
slug?: T;
tenant?: T;
updatedAt?: T;
createdAt?: T;
}
@@ -187,6 +188,7 @@ export interface PagesSelect<T extends boolean = true> {
*/
export interface UsersSelect<T extends boolean = true> {
roles?: T;
username?: T;
tenants?:
| T
| {
@@ -194,7 +196,6 @@ export interface UsersSelect<T extends boolean = true> {
roles?: T;
id?: T;
};
username?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
@@ -211,8 +212,9 @@ export interface UsersSelect<T extends boolean = true> {
*/
export interface TenantsSelect<T extends boolean = true> {
name?: T;
domain?: T;
slug?: T;
public?: T;
allowPublicRead?: T;
updatedAt?: T;
createdAt?: T;
}

View File

@@ -7,6 +7,10 @@ import { fileURLToPath } from 'url'
import { Pages } from './collections/Pages'
import { Tenants } from './collections/Tenants'
import Users from './collections/Users'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import { isSuperAdmin } from './access/isSuperAdmin'
import type { Config } from './payload-types'
import { getUserTenantIDs } from './utilities/getUserTenantIDs'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -14,9 +18,6 @@ const dirname = path.dirname(filename)
// eslint-disable-next-line no-restricted-exports
export default buildConfig({
admin: {
components: {
afterNavLinks: ['@/components/TenantSelector#TenantSelectorRSC'],
},
user: 'users',
},
collections: [Pages, Users, Tenants],
@@ -31,4 +32,26 @@ export default buildConfig({
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
plugins: [
multiTenantPlugin<Config>({
collections: {
pages: {},
},
tenantField: {
access: {
read: () => true,
update: ({ req }) => {
if (isSuperAdmin(req.user)) {
return true
}
return getUserTenantIDs(req.user).length > 0
},
},
},
tenantsArrayField: {
includeDefaultField: false,
},
userHasAccessToAllTenants: (user) => isSuperAdmin(user),
}),
],
})

View File

@@ -1,31 +0,0 @@
import type { Tenant, User } from '../payload-types'
import { extractID } from './extractID'
export const getTenantAccessIDs = (user: null | User): Tenant['id'][] => {
if (!user) {
return []
}
return (
user?.tenants?.reduce<Tenant['id'][]>((acc, { tenant }) => {
if (tenant) {
acc.push(extractID(tenant))
}
return acc
}, []) || []
)
}
export const getTenantAdminTenantAccessIDs = (user: null | User): Tenant['id'][] => {
if (!user) {
return []
}
return (
user?.tenants?.reduce<Tenant['id'][]>((acc, { roles, tenant }) => {
if (roles.includes('tenant-admin') && tenant) {
acc.push(extractID(tenant))
}
return acc
}, []) || []
)
}

View File

@@ -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<User['tenants']>[number]['roles'][number],
): Tenant['id'][] => {
if (!user) {
return []
}
return (
user?.tenants?.reduce<Tenant['id'][]>((acc, { roles, tenant }) => {
if (role && !roles.includes(role)) {
return acc
}
if (tenant) {
acc.push(extractID(tenant))
}
return acc
}, []) || []
)
}

View File

@@ -1,10 +0,0 @@
import type { PayloadRequest } from 'payload'
export const isPayloadAdminPanel = (req: PayloadRequest) => {
return (
req.headers.has('referer') &&
req.headers
.get('referer')
?.startsWith(`${process.env.NEXT_PUBLIC_SERVER_URL}${req.payload.config.routes.admin}`)
)
}

View File

@@ -40,7 +40,6 @@
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"src/mocks/emptyObject.js"
],
"exclude": [
"node_modules"