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:
254
docs/plugins/multi-tenant.mdx
Normal file
254
docs/plugins/multi-tenant.mdx
Normal file
@@ -0,0 +1,254 @@
|
||||
---
|
||||
title: Multi-Tenant Plugin
|
||||
label: Multi-Tenant
|
||||
order: 40
|
||||
desc: Scaffolds multi-tenancy for your Payload application
|
||||
keywords: plugins, multi-tenant, multi-tenancy, plugin, payload, cms, seo, indexing, search, search engine
|
||||
---
|
||||
|
||||
[](https://www.npmjs.com/package/@payloadcms/plugin-multi-tenants)
|
||||
|
||||
This plugin sets up multi-tenancy for your application from within your [Admin Panel](../admin/overview). It does so by adding a `tenant` field to all specified collections. Your front-end application can then query data by tenant. You must add the Tenants collection so you control what fields are available for each tenant.
|
||||
|
||||
|
||||
<Banner type="info">
|
||||
This plugin is completely open-source and the [source code can be found
|
||||
here](https://github.com/payloadcms/payload/tree/main/packages/plugin-multi-tenant). If you need
|
||||
help, check out our [Community Help](https://payloadcms.com/community-help). If you think you've
|
||||
found a bug, please [open a new
|
||||
issue](https://github.com/payloadcms/payload/issues/new?assignees=&labels=plugin%3A%multi-tenant&template=bug_report.md&title=plugin-multi-tenant%3A)
|
||||
with as much detail as possible.
|
||||
</Banner>
|
||||
|
||||
## Core features
|
||||
|
||||
- Adds a `tenant` field to each specified collection
|
||||
- Adds a tenant selector to the admin panel, allowing you to switch between tenants
|
||||
- Filters list view results by selected tenant
|
||||
|
||||
## Installation
|
||||
|
||||
Install the plugin using any JavaScript package manager like [pnpm](https://pnpm.io), [npm](https://npmjs.com), or [Yarn](https://yarnpkg.com):
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/plugin-multi-tenant@beta
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
The plugin accepts an object with the following properties:
|
||||
|
||||
```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 } ? ConfigTypes['user'] : User,
|
||||
) => boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
In the `plugins` array of your [Payload Config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options):
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
|
||||
import type { Config } from './payload-types'
|
||||
|
||||
const config = buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug: 'tenants',
|
||||
admin: {
|
||||
useAsTitle: 'name'
|
||||
}
|
||||
fields: [
|
||||
// remember, you own these fields
|
||||
// these are merely suggestions/examples
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'domain',
|
||||
type: 'text',
|
||||
required: true,
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
multiTenantPlugin<Config>({
|
||||
collections: {
|
||||
pages: {},
|
||||
navigation: {
|
||||
isGlobal: true,
|
||||
}
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
```
|
||||
|
||||
## Front end usage
|
||||
|
||||
The plugin scaffolds out everything you will need to separate data by tenant. You can use the `tenant` field to filter data from enabled collections in your front-end application.
|
||||
|
||||
|
||||
In your frontend you can query and constrain data by tenant with the following:
|
||||
|
||||
```tsx
|
||||
const pagesBySlug = await payload.find({
|
||||
collection: 'pages',
|
||||
depth: 1,
|
||||
draft: false,
|
||||
limit: 1000,
|
||||
overrideAccess: false,
|
||||
where: {
|
||||
// your constraint would depend on the
|
||||
// fields you added to the tenants collection
|
||||
// here we are assuming a slug field exists
|
||||
// on the tenant collection, like in the example above
|
||||
'tenant.slug': {
|
||||
equals: 'gold',
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### NextJS rewrites
|
||||
|
||||
Using NextJS rewrites and this route structure `/[tenantDomain]/[slug]`, we can rewrite routes specifically for domains requested:
|
||||
|
||||
```ts
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/((?!admin|api)):path*',
|
||||
destination: '/:tenantDomain/:path*',
|
||||
has: [
|
||||
{
|
||||
type: 'host',
|
||||
value: '(?<tenantDomain>.*)',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
The [Examples Directory](https://github.com/payloadcms/payload/tree/main/examples) also contains an official [Multi-Tenant](https://github.com/payloadcms/payload/tree/main/examples/multi-tenant) example.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
375
examples/multi-tenant/pnpm-lock.yaml
generated
375
examples/multi-tenant/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { Access } from 'payload'
|
||||
import { User } from '../payload-types'
|
||||
|
||||
export const isSuperAdmin: Access = ({ req }) => {
|
||||
if (!req?.user) {
|
||||
return false
|
||||
export const isSuperAdminAccess: Access = ({ req }): boolean => {
|
||||
return isSuperAdmin(req.user)
|
||||
}
|
||||
return Boolean(req.user.roles?.includes('super-admin'))
|
||||
|
||||
export const isSuperAdmin = (user: User | null): boolean => {
|
||||
return Boolean(user?.roles?.includes('super-admin'))
|
||||
}
|
||||
|
||||
30
examples/multi-tenant/src/app/(app)/page.tsx
Normal file
30
examples/multi-tenant/src/app/(app)/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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,6 +20,9 @@ export default async function Page({
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers })
|
||||
|
||||
const slug = params?.slug
|
||||
|
||||
try {
|
||||
const tenantsQuery = await payload.find({
|
||||
collection: 'tenants',
|
||||
overrideAccess: false,
|
||||
@@ -30,15 +33,21 @@ export default async function Page({
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const slug = params?.slug
|
||||
|
||||
// If no tenant is found, the user does not have access
|
||||
// Show the login view
|
||||
if (tenantsQuery.docs.length === 0) {
|
||||
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('/')}` : ''}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
} 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(
|
||||
`/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent(
|
||||
`/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`,
|
||||
)}`,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
import Page from './[...slug]/page'
|
||||
|
||||
export default Page
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
if (!args.req.user) {
|
||||
return {
|
||||
allowPublicRead: {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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 {
|
||||
or: [
|
||||
{
|
||||
id: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
'tenants.tenant': {
|
||||
in: adminTenantAccessIDs,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as Where
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +25,13 @@ export const externalUsersLogin: Endpoint = {
|
||||
const fullTenant = (
|
||||
await req.payload.find({
|
||||
collection: 'tenants',
|
||||
where: {
|
||||
where: tenantDomain
|
||||
? {
|
||||
domain: {
|
||||
equals: tenantDomain,
|
||||
},
|
||||
}
|
||||
: {
|
||||
slug: {
|
||||
equals: tenantSlug,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
access: {
|
||||
update: ({ req }) => {
|
||||
return isSuperAdmin(req.user)
|
||||
},
|
||||
{
|
||||
name: 'tenants',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'tenant',
|
||||
type: 'relationship',
|
||||
index: true,
|
||||
relationTo: 'tenants',
|
||||
required: true,
|
||||
saveToJWT: true,
|
||||
},
|
||||
{
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export default {}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}, []) || []
|
||||
)
|
||||
}
|
||||
31
examples/multi-tenant/src/utilities/getUserTenantIDs.ts
Normal file
31
examples/multi-tenant/src/utilities/getUserTenantIDs.ts
Normal 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
|
||||
}, []) || []
|
||||
)
|
||||
}
|
||||
@@ -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}`)
|
||||
)
|
||||
}
|
||||
@@ -40,7 +40,6 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"src/mocks/emptyObject.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"build:payload-cloud": "turbo build --filter \"@payloadcms/payload-cloud\"",
|
||||
"build:plugin-cloud-storage": "turbo build --filter \"@payloadcms/plugin-cloud-storage\"",
|
||||
"build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"",
|
||||
"build:plugin-multi-tenant": "turbo build --filter \"@payloadcms/plugin-multi-tenant\"",
|
||||
"build:plugin-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"",
|
||||
"build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"",
|
||||
"build:plugin-search": "turbo build --filter \"@payloadcms/plugin-search\"",
|
||||
|
||||
@@ -20,13 +20,20 @@ const baseClass = 'template-default'
|
||||
export type DefaultTemplateProps = {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
globalSlug?: string
|
||||
viewActions?: CustomComponent[]
|
||||
viewType?: 'edit' | 'list'
|
||||
visibleEntities: VisibleEntities
|
||||
} & ServerProps
|
||||
|
||||
export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
|
||||
children,
|
||||
className,
|
||||
collectionSlug,
|
||||
docID,
|
||||
globalSlug,
|
||||
i18n,
|
||||
locale,
|
||||
params,
|
||||
@@ -35,6 +42,7 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
|
||||
searchParams,
|
||||
user,
|
||||
viewActions,
|
||||
viewType,
|
||||
visibleEntities,
|
||||
}) => {
|
||||
const {
|
||||
@@ -50,6 +58,20 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
|
||||
|
||||
const serverProps = React.useMemo<ServerProps>(
|
||||
() => ({
|
||||
collectionSlug,
|
||||
docID,
|
||||
globalSlug,
|
||||
i18n,
|
||||
locale,
|
||||
params,
|
||||
payload,
|
||||
permissions,
|
||||
searchParams,
|
||||
user,
|
||||
viewType,
|
||||
visibleEntities,
|
||||
}),
|
||||
[
|
||||
i18n,
|
||||
locale,
|
||||
params,
|
||||
@@ -58,8 +80,11 @@ export const DefaultTemplate: React.FC<DefaultTemplateProps> = ({
|
||||
searchParams,
|
||||
user,
|
||||
visibleEntities,
|
||||
}),
|
||||
[i18n, locale, params, payload, permissions, searchParams, user, visibleEntities],
|
||||
globalSlug,
|
||||
collectionSlug,
|
||||
docID,
|
||||
viewType,
|
||||
],
|
||||
)
|
||||
|
||||
const { Actions } = React.useMemo<{
|
||||
|
||||
@@ -71,6 +71,7 @@ type ServerPropsFromView = {
|
||||
collectionConfig?: SanitizedConfig['collections'][number]
|
||||
globalConfig?: SanitizedConfig['globals'][number]
|
||||
viewActions: CustomComponent[]
|
||||
viewType?: 'edit' | 'list'
|
||||
}
|
||||
|
||||
type GetViewFromConfigArgs = {
|
||||
@@ -203,6 +204,7 @@ export const getViewFromConfig = ({
|
||||
|
||||
templateClassName = `${segmentTwo}-list`
|
||||
templateType = 'default'
|
||||
serverProps.viewType = 'list'
|
||||
serverProps.viewActions = serverProps.viewActions.concat(
|
||||
matchedCollection.admin.components?.views?.list?.actions,
|
||||
)
|
||||
@@ -249,6 +251,7 @@ export const getViewFromConfig = ({
|
||||
|
||||
templateClassName = `collection-default-edit`
|
||||
templateType = 'default'
|
||||
serverProps.viewType = 'edit'
|
||||
|
||||
// Adds view actions to the current collection view
|
||||
if (matchedCollection.admin?.components?.views?.edit) {
|
||||
|
||||
@@ -130,6 +130,7 @@ export const RootPage = async ({
|
||||
serverProps: {
|
||||
...serverProps,
|
||||
clientConfig,
|
||||
docID: initPageResult?.docID,
|
||||
i18n: initPageResult?.req.i18n,
|
||||
importMap,
|
||||
initPageResult,
|
||||
@@ -147,6 +148,9 @@ export const RootPage = async ({
|
||||
)}
|
||||
{templateType === 'default' && (
|
||||
<DefaultTemplate
|
||||
collectionSlug={initPageResult?.collectionConfig?.slug}
|
||||
docID={initPageResult?.docID}
|
||||
globalSlug={initPageResult?.globalConfig?.slug}
|
||||
i18n={initPageResult?.req.i18n}
|
||||
locale={initPageResult?.locale}
|
||||
params={params}
|
||||
@@ -155,6 +159,7 @@ export const RootPage = async ({
|
||||
searchParams={searchParams}
|
||||
user={initPageResult?.req.user}
|
||||
viewActions={serverProps.viewActions}
|
||||
viewType={serverProps.viewType}
|
||||
visibleEntities={{
|
||||
// The reason we are not passing in initPageResult.visibleEntities directly is due to a "Cannot assign to read only property of object '#<Object>" error introduced in React 19
|
||||
// which this caused as soon as initPageResult.visibleEntities is passed in
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PAYLOAD_PACKAGE_LIST = [
|
||||
'@payloadcms/plugin-cloud-storage',
|
||||
'@payloadcms/payload-cloud',
|
||||
'@payloadcms/plugin-form-builder',
|
||||
'@payloadcms/plugin-multi-tenant',
|
||||
'@payloadcms/plugin-nested-docs',
|
||||
'@payloadcms/plugin-redirects',
|
||||
'@payloadcms/plugin-search',
|
||||
|
||||
7
packages/plugin-multi-tenant/.gitignore
vendored
Normal file
7
packages/plugin-multi-tenant/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.env
|
||||
dist
|
||||
demo/uploads
|
||||
build
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
12
packages/plugin-multi-tenant/.prettierignore
Normal file
12
packages/plugin-multi-tenant/.prettierignore
Normal file
@@ -0,0 +1,12 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
**/docs/**
|
||||
tsconfig.json
|
||||
24
packages/plugin-multi-tenant/.swcrc
Normal file
24
packages/plugin-multi-tenant/.swcrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
22
packages/plugin-multi-tenant/LICENSE.md
Normal file
22
packages/plugin-multi-tenant/LICENSE.md
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2024 Payload CMS, Inc. <info@payloadcms.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
239
packages/plugin-multi-tenant/README.md
Normal file
239
packages/plugin-multi-tenant/README.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Multi Tenant Plugin
|
||||
|
||||
A plugin for [Payload](https://github.com/payloadcms/payload) to easily manage multiple tenants from within your admin panel.
|
||||
|
||||
- [Source code](https://github.com/payloadcms/payload/tree/main/packages/plugin-multi-tenant)
|
||||
- [Documentation](https://payloadcms.com/docs/plugins/multi-tenant)
|
||||
- [Documentation source](https://github.com/payloadcms/payload/tree/main/docs/plugins/multi-tenant.mdx)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/plugin-multi-tenant@beta
|
||||
```
|
||||
|
||||
## Plugin Types
|
||||
|
||||
```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 } ? ConfigTypes['user'] : User,
|
||||
) => boolean
|
||||
}
|
||||
```
|
||||
|
||||
### 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Customizing access control
|
||||
|
||||
In some cases, the access control supplied out of the box may be too strict. For example, if you need _some_ documents to be shared between tenants, you will need to opt out of the supplied access control functionality.
|
||||
|
||||
By default this plugin merges your access control result with a constraint based on tenants the user has access to within an _AND_ condition. That would not work for the above scenario.
|
||||
|
||||
In the multi-tenant plugin config you can set `useTenantAccess` to false:
|
||||
|
||||
```ts
|
||||
// File: payload.config.ts
|
||||
|
||||
import { buildConfig } from 'payload'
|
||||
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
|
||||
import { getTenantAccess } from '@payloadcms/plugin-multi-tenant/utilities'
|
||||
import { Config as ConfigTypes } from './payload-types'
|
||||
|
||||
// Add the plugin to your payload config
|
||||
export default buildConfig({
|
||||
plugins: [
|
||||
multiTenantPlugin({
|
||||
collections: {
|
||||
media: {
|
||||
useTenantAccess: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
collections: [
|
||||
{
|
||||
slug: 'media',
|
||||
fields: [
|
||||
{
|
||||
name: 'isShared',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
// you likely want to set access control on fields like this
|
||||
// to prevent just any user from modifying it
|
||||
},
|
||||
],
|
||||
access: {
|
||||
read: ({ req, doc }) => {
|
||||
if (!req.user) return false
|
||||
|
||||
const whereConstraint = {
|
||||
or: [
|
||||
{
|
||||
isShared: {
|
||||
equals: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const tenantAccessResult = getTenantAccess({ user: req.user })
|
||||
|
||||
if (tenantAccessResult) {
|
||||
whereConstraint.or.push(tenantAccessResult)
|
||||
}
|
||||
|
||||
return whereConstraint
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
### Placing the tenants array field
|
||||
|
||||
In your users collection you may want to place the field in a tab or in the sidebar, or customize some of the properties on it.
|
||||
|
||||
You can use the `tenantsArrayField.includeDefaultField: false` setting in the plugin config. You will then need to manually add a `tenants` array field in your users collection.
|
||||
|
||||
This field cannot be nested inside a named field, ie a group, named-tab or array. It _can_ be nested inside a row, unnamed-tab, collapsible.
|
||||
|
||||
To make it easier, this plugin exports the field for you to import and merge in your own properties.
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
|
||||
|
||||
const customTenantsArrayField = tenantsArrayField({
|
||||
arrayFieldAccess: {}, // access control for the array field
|
||||
tenantFieldAccess: {}, // access control for the tenants field on the array row
|
||||
rowFields: [], // additional row fields
|
||||
})
|
||||
|
||||
export const UsersCollection: CollectionConfig = {
|
||||
slug: 'users',
|
||||
fields: [
|
||||
{
|
||||
...customTenantsArrayField,
|
||||
label: 'Associated Tenants',
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
18
packages/plugin-multi-tenant/eslint.config.js
Normal file
18
packages/plugin-multi-tenant/eslint.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
|
||||
|
||||
/** @typedef {import('eslint').Linter.Config} Config */
|
||||
|
||||
/** @type {Config[]} */
|
||||
export const index = [
|
||||
...rootEslintConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
...rootParserOptions,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default index
|
||||
126
packages/plugin-multi-tenant/package.json
Normal file
126
packages/plugin-multi-tenant/package.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-multi-tenant",
|
||||
"version": "0.0.1",
|
||||
"description": "Multi Tenant plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
"cms",
|
||||
"plugin",
|
||||
"typescript",
|
||||
"react",
|
||||
"multi-tenant",
|
||||
"nextjs"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/payloadcms/payload.git",
|
||||
"directory": "packages/plugin-multi-tenant"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Payload",
|
||||
"email": "info@payloadcms.com",
|
||||
"url": "https://payloadcms.com"
|
||||
}
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./fields": {
|
||||
"import": "./src/exports/fields.ts",
|
||||
"types": "./src/exports/fields.ts",
|
||||
"default": "./src/exports/fields.ts"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./src/exports/types.ts",
|
||||
"types": "./src/exports/types.ts",
|
||||
"default": "./src/exports/types.ts"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./src/exports/rsc.ts",
|
||||
"types": "./src/exports/rsc.ts",
|
||||
"default": "./src/exports/rsc.ts"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./src/exports/client.ts",
|
||||
"types": "./src/exports/client.ts",
|
||||
"default": "./src/exports/client.ts"
|
||||
},
|
||||
"./utilities": {
|
||||
"import": "./src/exports/utilities.ts",
|
||||
"types": "./src/exports/utilities.ts",
|
||||
"default": "./src/exports/utilities.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist",
|
||||
"types.js",
|
||||
"types.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"clean": "rimraf {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"pack:plugin": "pnpm prepublishOnly && pnpm copyfiles && pnpm pack",
|
||||
"prepublishOnly": "pnpm clean && pnpm turbo build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@payloadcms/ui": "workspace:*",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@payloadcms/ui": "workspace:*",
|
||||
"next": "^15.0.3",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./fields": {
|
||||
"import": "./dist/exports/fields.js",
|
||||
"types": "./dist/exports/fields.d.ts",
|
||||
"default": "./dist/exports/fields.js"
|
||||
},
|
||||
"./types": {
|
||||
"import": "./dist/exports/types.js",
|
||||
"types": "./dist/exports/types.d.ts",
|
||||
"default": "./dist/exports/types.js"
|
||||
},
|
||||
"./rsc": {
|
||||
"import": "./dist/exports/rsc.js",
|
||||
"types": "./dist/exports/rsc.d.ts",
|
||||
"default": "./dist/exports/rsc.js"
|
||||
},
|
||||
"./client": {
|
||||
"import": "./dist/exports/client.js",
|
||||
"types": "./dist/exports/client.d.ts",
|
||||
"default": "./dist/exports/client.js"
|
||||
},
|
||||
"./utilities": {
|
||||
"import": "./dist/exports/utilities.js",
|
||||
"types": "./dist/exports/utilities.d.ts",
|
||||
"default": "./dist/exports/utilities.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"homepage:": "https://payloadcms.com"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CollectionSlug, ServerProps } from 'payload'
|
||||
|
||||
import { redirect } from 'next/navigation.js'
|
||||
|
||||
import { getGlobalViewRedirect } from '../../utilities/getGlobalViewRedirect.js'
|
||||
|
||||
type Args = {
|
||||
collectionSlug: CollectionSlug
|
||||
docID?: number | string
|
||||
globalSlugs: string[]
|
||||
tenantFieldName: string
|
||||
viewType: 'edit' | 'list'
|
||||
} & ServerProps
|
||||
|
||||
export const GlobalViewRedirect = async (args: Args) => {
|
||||
const collectionSlug = args?.collectionSlug
|
||||
|
||||
if (collectionSlug && args.globalSlugs?.includes(collectionSlug)) {
|
||||
const redirectRoute = await getGlobalViewRedirect({
|
||||
slug: collectionSlug,
|
||||
docID: args.docID,
|
||||
payload: args.payload,
|
||||
tenantFieldName: args.tenantFieldName,
|
||||
view: args.viewType,
|
||||
})
|
||||
|
||||
if (redirectRoute) {
|
||||
redirect(redirectRoute)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client'
|
||||
|
||||
import type { RelationshipFieldClientProps } from 'payload'
|
||||
|
||||
import { RelationshipField, useField } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
import { SELECT_ALL } from '../../constants.js'
|
||||
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.js'
|
||||
|
||||
const baseClass = 'tenantField'
|
||||
|
||||
type Props = {
|
||||
debug?: boolean
|
||||
unique?: boolean
|
||||
} & RelationshipFieldClientProps
|
||||
|
||||
export const TenantField = (args: Props) => {
|
||||
const { debug, path, unique } = args
|
||||
const { setValue, value } = useField<number | string>({ path })
|
||||
const { options, selectedTenantID, setRefreshOnChange, setTenant } = useTenantSelection()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedTenantID && value) {
|
||||
// Initialize the tenant selector with the field value
|
||||
setTenant({ id: value, from: 'document' })
|
||||
} else if (selectedTenantID && selectedTenantID === SELECT_ALL && options?.[0]?.value) {
|
||||
setTenant({ id: options[0].value, from: 'document' })
|
||||
} else if ((!value || value !== selectedTenantID) && selectedTenantID) {
|
||||
// Update the field value when the tenant is changed
|
||||
setValue(selectedTenantID)
|
||||
}
|
||||
}, [value, selectedTenantID, setTenant, setValue, options])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!unique) {
|
||||
setRefreshOnChange(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!unique) {
|
||||
setRefreshOnChange(true)
|
||||
}
|
||||
}
|
||||
}, [setRefreshOnChange, unique])
|
||||
|
||||
if (debug) {
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
<RelationshipField {...args} />
|
||||
</div>
|
||||
<div className={`${baseClass}__hr`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
.tenantField {
|
||||
&__wrapper {
|
||||
margin-top: calc(-.75 * var(--spacing-field));
|
||||
margin-bottom: var(--spacing-field);
|
||||
width: 25%;
|
||||
}
|
||||
&__hr {
|
||||
width: calc(100% + 2 * var(--gutter-h));
|
||||
margin-left: calc(-1 * var(--gutter-h));
|
||||
background-color: var(--theme-elevation-100);
|
||||
height: 1px;
|
||||
margin-bottom: var(--spacing-field);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
import type { ReactSelectOption } from '@payloadcms/ui'
|
||||
import type { OptionObject } from 'payload'
|
||||
|
||||
import { SelectInput } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
import { SELECT_ALL } from '../../constants.js'
|
||||
import { useTenantSelection } from '../../providers/TenantSelectionProvider/index.js'
|
||||
|
||||
export const TenantSelectorClient = ({
|
||||
initialValue,
|
||||
options,
|
||||
}: {
|
||||
initialValue?: string
|
||||
options: OptionObject[]
|
||||
}) => {
|
||||
const { selectedTenantID, setOptions, setTenant } = useTenantSelection()
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(option: ReactSelectOption | ReactSelectOption[]) => {
|
||||
if (option && 'value' in option) {
|
||||
setTenant({ id: option.value as string, from: 'document', refresh: true })
|
||||
} else {
|
||||
setTenant({ id: undefined, from: 'document', refresh: true })
|
||||
}
|
||||
},
|
||||
[setTenant],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!selectedTenantID && initialValue) {
|
||||
setTenant({ id: initialValue, from: 'cookie', refresh: true })
|
||||
} else if (selectedTenantID && !options.find((option) => option.value === selectedTenantID)) {
|
||||
if (options?.[0]?.value) {
|
||||
// this runs if the user has a selected value that is no longer a valid option
|
||||
setTenant({ id: options[0].value, from: 'document', refresh: true })
|
||||
} else {
|
||||
setTenant({ id: undefined, from: 'document', refresh: true })
|
||||
}
|
||||
}
|
||||
}, [initialValue, setTenant, selectedTenantID, options])
|
||||
|
||||
React.useEffect(() => {
|
||||
setOptions(options)
|
||||
}, [options, setOptions])
|
||||
|
||||
if (options.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tenant-selector">
|
||||
<SelectInput
|
||||
label="Tenant"
|
||||
name="setTenant"
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
path="setTenant"
|
||||
value={
|
||||
selectedTenantID
|
||||
? selectedTenantID === SELECT_ALL
|
||||
? undefined
|
||||
: String(selectedTenantID)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
.tenant-selector {
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { OptionObject, ServerProps } from 'payload'
|
||||
|
||||
import { cookies as getCookies } from 'next/headers.js'
|
||||
import React from 'react'
|
||||
|
||||
import type { UserWithTenantsField } from '../../types.js'
|
||||
|
||||
import { TenantSelectorClient } from './index.client.js'
|
||||
|
||||
type Args = {
|
||||
tenantsCollectionSlug: string
|
||||
useAsTitle: string
|
||||
user?: UserWithTenantsField
|
||||
} & ServerProps
|
||||
|
||||
export const TenantSelector = async ({
|
||||
payload,
|
||||
tenantsCollectionSlug,
|
||||
useAsTitle,
|
||||
user,
|
||||
}: Args) => {
|
||||
const { docs: userTenants } = await payload.find({
|
||||
collection: tenantsCollectionSlug,
|
||||
depth: 0,
|
||||
limit: 1000,
|
||||
overrideAccess: false,
|
||||
sort: useAsTitle,
|
||||
user,
|
||||
})
|
||||
|
||||
const tenantOptions: OptionObject[] = userTenants.map((doc) => ({
|
||||
label: String(doc[useAsTitle]),
|
||||
value: String(doc.id),
|
||||
}))
|
||||
|
||||
const cookies = await getCookies()
|
||||
let selectedTenant = tenantOptions.find(
|
||||
(tenant) => tenant.value === cookies.get('payload-tenant')?.value,
|
||||
)?.value
|
||||
|
||||
if (!selectedTenant && userTenants?.[0]) {
|
||||
selectedTenant = String(userTenants[0].id)
|
||||
}
|
||||
|
||||
return <TenantSelectorClient initialValue={selectedTenant} options={tenantOptions} />
|
||||
}
|
||||
2
packages/plugin-multi-tenant/src/constants.ts
Normal file
2
packages/plugin-multi-tenant/src/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// The tenant cookie can be set to _ALL_ to allow users to see all results for tenants they are a member of.
|
||||
export const SELECT_ALL = '_ALL_'
|
||||
2
packages/plugin-multi-tenant/src/exports/client.ts
Normal file
2
packages/plugin-multi-tenant/src/exports/client.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TenantField } from '../components/TenantField/index.client.js'
|
||||
export { TenantSelectionProvider } from '../providers/TenantSelectionProvider/index.js'
|
||||
2
packages/plugin-multi-tenant/src/exports/fields.ts
Normal file
2
packages/plugin-multi-tenant/src/exports/fields.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { tenantField } from '../fields/tenantField/index.js'
|
||||
export { tenantsArrayField } from '../fields/tenantsArrayField/index.js'
|
||||
2
packages/plugin-multi-tenant/src/exports/rsc.ts
Normal file
2
packages/plugin-multi-tenant/src/exports/rsc.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { GlobalViewRedirect } from '../components/GlobalViewRedirect/index.js'
|
||||
export { TenantSelector } from '../components/TenantSelector/index.js'
|
||||
1
packages/plugin-multi-tenant/src/exports/types.ts
Normal file
1
packages/plugin-multi-tenant/src/exports/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { MultiTenantPluginConfig } from '../types.js'
|
||||
5
packages/plugin-multi-tenant/src/exports/utilities.ts
Normal file
5
packages/plugin-multi-tenant/src/exports/utilities.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { getGlobalViewRedirect } from '../utilities/getGlobalViewRedirect.js'
|
||||
export { getTenantAccess } from '../utilities/getTenantAccess.js'
|
||||
export { getTenantFromCookie } from '../utilities/getTenantFromCookie.js'
|
||||
export { getTenantListFilter } from '../utilities/getTenantListFilter.js'
|
||||
export { getUserTenantIDs } from '../utilities/getUserTenantIDs.js'
|
||||
56
packages/plugin-multi-tenant/src/fields/tenantField/index.ts
Normal file
56
packages/plugin-multi-tenant/src/fields/tenantField/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { type RelationshipField } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import { getTenantFromCookie } from '../../utilities/getTenantFromCookie.js'
|
||||
|
||||
type Args = {
|
||||
access?: RelationshipField['access']
|
||||
debug?: boolean
|
||||
name: string
|
||||
tenantsCollectionSlug: string
|
||||
unique: boolean
|
||||
}
|
||||
export const tenantField = ({
|
||||
name,
|
||||
access = undefined,
|
||||
debug,
|
||||
tenantsCollectionSlug,
|
||||
unique,
|
||||
}: Args): RelationshipField => ({
|
||||
name,
|
||||
type: 'relationship',
|
||||
access,
|
||||
admin: {
|
||||
allowCreate: false,
|
||||
allowEdit: false,
|
||||
components: {
|
||||
Field: {
|
||||
clientProps: {
|
||||
debug,
|
||||
unique,
|
||||
},
|
||||
path: '@payloadcms/plugin-multi-tenant/client#TenantField',
|
||||
},
|
||||
},
|
||||
disableListColumn: true,
|
||||
disableListFilter: true,
|
||||
},
|
||||
hasMany: false,
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
({ req, value }) => {
|
||||
if (!value) {
|
||||
const tenantFromCookie = getTenantFromCookie(req.headers, req.payload.db.defaultIDType)
|
||||
if (tenantFromCookie) {
|
||||
return tenantFromCookie
|
||||
}
|
||||
throw new APIError('You must select a tenant', 400, null, true)
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
index: true,
|
||||
label: 'Assigned Tenant',
|
||||
relationTo: tenantsCollectionSlug,
|
||||
unique,
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ArrayField, RelationshipField } from 'payload'
|
||||
|
||||
export const tenantsArrayField = (args: {
|
||||
arrayFieldAccess?: ArrayField['access']
|
||||
rowFields?: ArrayField['fields']
|
||||
tenantFieldAccess?: RelationshipField['access']
|
||||
}): ArrayField => ({
|
||||
name: 'tenants',
|
||||
type: 'array',
|
||||
access: args?.arrayFieldAccess,
|
||||
fields: [
|
||||
{
|
||||
name: 'tenant',
|
||||
type: 'relationship',
|
||||
access: args.tenantFieldAccess,
|
||||
index: true,
|
||||
relationTo: 'tenants',
|
||||
required: true,
|
||||
saveToJWT: true,
|
||||
},
|
||||
...(args?.rowFields || []),
|
||||
],
|
||||
saveToJWT: true,
|
||||
})
|
||||
111
packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts
Normal file
111
packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type {
|
||||
CollectionAfterDeleteHook,
|
||||
CollectionConfig,
|
||||
JsonObject,
|
||||
PaginatedDocs,
|
||||
} from 'payload'
|
||||
|
||||
import { generateCookie, mergeHeaders } from 'payload'
|
||||
|
||||
import type { UserWithTenantsField } from '../types.js'
|
||||
|
||||
import { getTenantFromCookie } from '../utilities/getTenantFromCookie.js'
|
||||
|
||||
type Args = {
|
||||
collection: CollectionConfig
|
||||
enabledSlugs: string[]
|
||||
tenantFieldName: string
|
||||
usersSlug: string
|
||||
}
|
||||
/**
|
||||
* Add cleanup logic when tenant is deleted
|
||||
* - delete documents related to tenant
|
||||
* - remove tenant from users
|
||||
*/
|
||||
export const addTenantCleanup = ({
|
||||
collection,
|
||||
enabledSlugs,
|
||||
tenantFieldName,
|
||||
usersSlug,
|
||||
}: Args) => {
|
||||
if (!collection.hooks) {
|
||||
collection.hooks = {}
|
||||
}
|
||||
if (!collection.hooks?.afterDelete) {
|
||||
collection.hooks.afterDelete = []
|
||||
}
|
||||
collection.hooks.afterDelete.push(
|
||||
afterTenantDelete({
|
||||
enabledSlugs,
|
||||
tenantFieldName,
|
||||
usersSlug,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const afterTenantDelete =
|
||||
({
|
||||
enabledSlugs,
|
||||
tenantFieldName,
|
||||
usersSlug,
|
||||
}: Omit<Args, 'collection'>): CollectionAfterDeleteHook =>
|
||||
async ({ id, req }) => {
|
||||
const currentTenantCookieID = getTenantFromCookie(req.headers, req.payload.db.defaultIDType)
|
||||
if (currentTenantCookieID === id) {
|
||||
const newHeaders = new Headers({
|
||||
'Set-Cookie': generateCookie<string>({
|
||||
name: 'payload-tenant',
|
||||
expires: new Date(Date.now() - 1000),
|
||||
path: '/',
|
||||
returnCookieAsObject: false,
|
||||
value: '',
|
||||
}),
|
||||
})
|
||||
|
||||
req.responseHeaders = req.responseHeaders
|
||||
? mergeHeaders(req.responseHeaders, newHeaders)
|
||||
: newHeaders
|
||||
}
|
||||
const cleanupPromises: Promise<JsonObject>[] = []
|
||||
enabledSlugs.forEach((slug) => {
|
||||
cleanupPromises.push(
|
||||
req.payload.delete({
|
||||
collection: slug,
|
||||
where: {
|
||||
[tenantFieldName]: {
|
||||
equals: id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
const usersWithTenant = (await req.payload.find({
|
||||
collection: usersSlug,
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
where: {
|
||||
'tenants.tenant': {
|
||||
equals: id,
|
||||
},
|
||||
},
|
||||
})) as PaginatedDocs<UserWithTenantsField>
|
||||
|
||||
usersWithTenant?.docs?.forEach((user) => {
|
||||
cleanupPromises.push(
|
||||
req.payload.update({
|
||||
id: user.id,
|
||||
collection: usersSlug,
|
||||
data: {
|
||||
tenants: (user.tenants || []).filter(({ tenant: tenantID }) => tenantID !== id),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Error deleting tenants from users:', e)
|
||||
}
|
||||
|
||||
await Promise.all(cleanupPromises)
|
||||
}
|
||||
219
packages/plugin-multi-tenant/src/index.ts
Normal file
219
packages/plugin-multi-tenant/src/index.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import type { CollectionConfig, Config } from 'payload'
|
||||
|
||||
import type { MultiTenantPluginConfig } from './types.js'
|
||||
|
||||
import { tenantField } from './fields/tenantField/index.js'
|
||||
import { tenantsArrayField } from './fields/tenantsArrayField/index.js'
|
||||
import { addTenantCleanup } from './hooks/afterTenantDelete.js'
|
||||
import { addCollectionAccess } from './utilities/addCollectionAccess.js'
|
||||
import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js'
|
||||
import { withTenantListFilter } from './utilities/withTenantListFilter.js'
|
||||
|
||||
const defaults = {
|
||||
tenantCollectionSlug: 'tenants',
|
||||
tenantFieldName: 'tenant',
|
||||
userTenantsArrayFieldName: 'tenants',
|
||||
}
|
||||
|
||||
export const multiTenantPlugin =
|
||||
<ConfigType>(pluginConfig: MultiTenantPluginConfig<ConfigType>) =>
|
||||
(incomingConfig: Config): Config => {
|
||||
if (pluginConfig.enabled === false) {
|
||||
return incomingConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Set defaults
|
||||
*/
|
||||
const userHasAccessToAllTenants: Required<
|
||||
MultiTenantPluginConfig<ConfigType>
|
||||
>['userHasAccessToAllTenants'] =
|
||||
typeof pluginConfig.userHasAccessToAllTenants === 'function'
|
||||
? pluginConfig.userHasAccessToAllTenants
|
||||
: () => false
|
||||
const tenantsCollectionSlug = (pluginConfig.tenantsSlug =
|
||||
pluginConfig.tenantsSlug || defaults.tenantCollectionSlug)
|
||||
const tenantFieldName = pluginConfig?.tenantField?.name || defaults.tenantFieldName
|
||||
|
||||
/**
|
||||
* Add defaults for admin properties
|
||||
*/
|
||||
if (!incomingConfig.admin) {
|
||||
incomingConfig.admin = {}
|
||||
}
|
||||
if (!incomingConfig.admin?.components) {
|
||||
incomingConfig.admin.components = {
|
||||
actions: [],
|
||||
beforeNavLinks: [],
|
||||
providers: [],
|
||||
}
|
||||
}
|
||||
if (!incomingConfig.admin.components?.providers) {
|
||||
incomingConfig.admin.components.providers = []
|
||||
}
|
||||
if (!incomingConfig.admin.components?.actions) {
|
||||
incomingConfig.admin.components.actions = []
|
||||
}
|
||||
if (!incomingConfig.admin.components?.beforeNavLinks) {
|
||||
incomingConfig.admin.components.beforeNavLinks = []
|
||||
}
|
||||
if (!incomingConfig.collections) {
|
||||
incomingConfig.collections = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tenants array field to users collection
|
||||
*/
|
||||
const adminUsersCollection = incomingConfig.collections.find(({ slug, auth }) => {
|
||||
if (incomingConfig.admin?.user) {
|
||||
return slug === incomingConfig.admin.user
|
||||
} else if (auth) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
if (!adminUsersCollection) {
|
||||
throw Error('An auth enabled collection was not found')
|
||||
}
|
||||
|
||||
/**
|
||||
* Add TenantSelectionProvider to admin providers
|
||||
*/
|
||||
incomingConfig.admin.components.providers.push({
|
||||
path: '@payloadcms/plugin-multi-tenant/client#TenantSelectionProvider',
|
||||
})
|
||||
|
||||
/**
|
||||
* Add tenants array field to users collection
|
||||
*/
|
||||
if (pluginConfig?.tenantsArrayField?.includeDefaultField !== false) {
|
||||
adminUsersCollection.fields.push(tenantsArrayField(pluginConfig?.tenantsArrayField || {}))
|
||||
}
|
||||
|
||||
let tenantCollection: CollectionConfig | undefined
|
||||
|
||||
const [collectionSlugs, globalCollectionSlugs] = Object.keys(pluginConfig.collections).reduce<
|
||||
[string[], string[]]
|
||||
>(
|
||||
(acc, slug) => {
|
||||
if (pluginConfig?.collections?.[slug]?.isGlobal) {
|
||||
acc[1].push(slug)
|
||||
} else {
|
||||
acc[0].push(slug)
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
[[], []],
|
||||
)
|
||||
|
||||
/**
|
||||
* Modify collections
|
||||
*/
|
||||
incomingConfig.collections.forEach((collection) => {
|
||||
/**
|
||||
* Modify tenants collection
|
||||
*/
|
||||
if (collection.slug === tenantsCollectionSlug) {
|
||||
tenantCollection = collection
|
||||
|
||||
addCollectionAccess({
|
||||
collection,
|
||||
fieldName: 'id',
|
||||
userHasAccessToAllTenants,
|
||||
})
|
||||
|
||||
if (pluginConfig.cleanupAfterTenantDelete !== false) {
|
||||
/**
|
||||
* Add cleanup logic when tenant is deleted
|
||||
* - delete documents related to tenant
|
||||
* - remove tenant from users
|
||||
*/
|
||||
addTenantCleanup({
|
||||
collection,
|
||||
enabledSlugs: [...collectionSlugs, ...globalCollectionSlugs],
|
||||
tenantFieldName,
|
||||
usersSlug: adminUsersCollection.slug,
|
||||
})
|
||||
}
|
||||
} else if (pluginConfig.collections?.[collection.slug]) {
|
||||
/**
|
||||
* Modify enabled collections
|
||||
*/
|
||||
addFilterOptionsToFields({
|
||||
fields: collection.fields,
|
||||
tenantEnabledCollectionSlugs: collectionSlugs,
|
||||
tenantEnabledGlobalSlugs: globalCollectionSlugs,
|
||||
})
|
||||
|
||||
/**
|
||||
* Add tenant field to enabled collections
|
||||
*/
|
||||
collection.fields.splice(
|
||||
0,
|
||||
0,
|
||||
tenantField({
|
||||
...(pluginConfig?.tenantField || {}),
|
||||
name: tenantFieldName,
|
||||
debug: pluginConfig.debug,
|
||||
tenantsCollectionSlug,
|
||||
unique: Boolean(pluginConfig.collections[collection.slug]?.isGlobal),
|
||||
}),
|
||||
)
|
||||
|
||||
if (pluginConfig.collections[collection.slug]?.useBaseListFilter !== false) {
|
||||
/**
|
||||
* Collection baseListFilter with selected tenant constraint (if selected)
|
||||
*/
|
||||
if (!collection.admin) {
|
||||
collection.admin = {}
|
||||
}
|
||||
collection.admin.baseListFilter = withTenantListFilter({
|
||||
baseListFilter: collection.admin?.baseListFilter,
|
||||
tenantFieldName,
|
||||
})
|
||||
}
|
||||
|
||||
if (pluginConfig.collections[collection.slug]?.useTenantAccess !== false) {
|
||||
/**
|
||||
* Add access control constraint to tenant enabled collection
|
||||
*/
|
||||
addCollectionAccess({
|
||||
collection,
|
||||
fieldName: tenantFieldName,
|
||||
userHasAccessToAllTenants,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!tenantCollection) {
|
||||
throw new Error(`Tenants collection not found with slug: ${tenantsCollectionSlug}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add global redirect action
|
||||
*/
|
||||
if (globalCollectionSlugs.length) {
|
||||
incomingConfig.admin.components.actions.push({
|
||||
path: '@payloadcms/plugin-multi-tenant/rsc#GlobalViewRedirect',
|
||||
serverProps: {
|
||||
globalSlugs: globalCollectionSlugs,
|
||||
tenantFieldName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tenant selector to admin UI
|
||||
*/
|
||||
incomingConfig.admin.components.beforeNavLinks.push({
|
||||
clientProps: {
|
||||
tenantsCollectionSlug,
|
||||
useAsTitle: tenantCollection.admin?.useAsTitle || 'id',
|
||||
},
|
||||
path: '@payloadcms/plugin-multi-tenant/rsc#TenantSelector',
|
||||
})
|
||||
|
||||
return incomingConfig
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import type { OptionObject } from 'payload'
|
||||
|
||||
import { useRouter } from 'next/navigation.js'
|
||||
import React, { createContext } from 'react'
|
||||
|
||||
import { SELECT_ALL } from '../../constants.js'
|
||||
|
||||
type ContextType = {
|
||||
options: OptionObject[]
|
||||
selectedTenantID: number | string | undefined
|
||||
setOptions: (options: OptionObject[]) => void
|
||||
setRefreshOnChange: (refresh: boolean) => void
|
||||
setTenant: (args: {
|
||||
from: 'cookie' | 'document'
|
||||
id: number | string | undefined
|
||||
refresh?: boolean
|
||||
}) => void
|
||||
}
|
||||
|
||||
const Context = createContext<ContextType>({
|
||||
options: [],
|
||||
selectedTenantID: undefined,
|
||||
setOptions: () => null,
|
||||
setRefreshOnChange: () => null,
|
||||
setTenant: () => null,
|
||||
})
|
||||
|
||||
export const TenantSelectionProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [selectedTenantID, setSelectedTenantID] = React.useState<number | string | undefined>(
|
||||
undefined,
|
||||
)
|
||||
const [tenantSelectionFrom, setTenantSelectionFrom] = React.useState<
|
||||
'cookie' | 'document' | undefined
|
||||
>(undefined)
|
||||
const [refreshOnChange, setRefreshOnChange] = React.useState<boolean>(true)
|
||||
const [options, setOptions] = React.useState<OptionObject[]>([])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const setCookie = React.useCallback((value?: string) => {
|
||||
const expires = '; expires=Fri, 31 Dec 9999 23:59:59 GMT'
|
||||
document.cookie = 'payload-tenant=' + (value || '') + expires + '; path=/'
|
||||
}, [])
|
||||
|
||||
const setTenant = React.useCallback<ContextType['setTenant']>(
|
||||
({ id, from, refresh }) => {
|
||||
if (from === 'cookie' && tenantSelectionFrom === 'document') {
|
||||
return
|
||||
}
|
||||
setTenantSelectionFrom(from)
|
||||
if (id === undefined) {
|
||||
setSelectedTenantID(SELECT_ALL)
|
||||
setCookie(SELECT_ALL)
|
||||
} else {
|
||||
setSelectedTenantID(id)
|
||||
setCookie(String(id))
|
||||
}
|
||||
if (refresh && refreshOnChange) {
|
||||
router.refresh()
|
||||
}
|
||||
},
|
||||
[
|
||||
tenantSelectionFrom,
|
||||
setSelectedTenantID,
|
||||
setTenantSelectionFrom,
|
||||
setCookie,
|
||||
router,
|
||||
refreshOnChange,
|
||||
],
|
||||
)
|
||||
|
||||
return (
|
||||
<Context.Provider
|
||||
value={{ options, selectedTenantID, setOptions, setRefreshOnChange, setTenant }}
|
||||
>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTenantSelection = () => React.useContext(Context)
|
||||
121
packages/plugin-multi-tenant/src/types.ts
Normal file
121
packages/plugin-multi-tenant/src/types.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { ArrayField, CollectionSlug, Field, RelationshipField, User } from 'payload'
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export type Tenant<IDType = number | string> = {
|
||||
id: IDType
|
||||
name: string
|
||||
}
|
||||
|
||||
export type UserWithTenantsField = {
|
||||
tenants: {
|
||||
tenant: number | string | Tenant
|
||||
}[]
|
||||
} & User
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Access, CollectionConfig } from 'payload'
|
||||
|
||||
import type { MultiTenantPluginConfig } from '../types.js'
|
||||
|
||||
import { withTenantAccess } from './withTenantAccess.js'
|
||||
|
||||
type AllAccessKeys<T extends readonly string[]> = T[number] extends keyof Omit<
|
||||
Required<CollectionConfig>['access'],
|
||||
'admin'
|
||||
>
|
||||
? keyof Omit<Required<CollectionConfig>['access'], 'admin'> extends T[number]
|
||||
? T
|
||||
: never
|
||||
: never
|
||||
|
||||
const collectionAccessKeys: AllAccessKeys<
|
||||
['create', 'read', 'update', 'delete', 'readVersions', 'unlock']
|
||||
> = ['create', 'read', 'update', 'delete', 'readVersions', 'unlock'] as const
|
||||
|
||||
type Args<ConfigType> = {
|
||||
collection: CollectionConfig
|
||||
fieldName: string
|
||||
userHasAccessToAllTenants: Required<
|
||||
MultiTenantPluginConfig<ConfigType>
|
||||
>['userHasAccessToAllTenants']
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds tenant access constraint to collection
|
||||
*/
|
||||
export const addCollectionAccess = <ConfigType>({
|
||||
collection,
|
||||
fieldName,
|
||||
userHasAccessToAllTenants,
|
||||
}: Args<ConfigType>): void => {
|
||||
if (!collection?.access) {
|
||||
collection.access = {}
|
||||
}
|
||||
collectionAccessKeys.reduce<{
|
||||
[key in (typeof collectionAccessKeys)[number]]?: Access
|
||||
}>((acc, key) => {
|
||||
if (!collection.access) {
|
||||
return acc
|
||||
}
|
||||
collection.access[key] = withTenantAccess<ConfigType>({
|
||||
accessFunction: collection.access?.[key],
|
||||
fieldName,
|
||||
userHasAccessToAllTenants,
|
||||
})
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Field, FilterOptionsProps, RelationshipField, Where } from 'payload'
|
||||
|
||||
import { getTenantFromCookie } from './getTenantFromCookie.js'
|
||||
|
||||
type AddFilterOptionsToFieldsArgs = {
|
||||
fields: Field[]
|
||||
tenantEnabledCollectionSlugs: string[]
|
||||
tenantEnabledGlobalSlugs: string[]
|
||||
}
|
||||
export function addFilterOptionsToFields({
|
||||
fields,
|
||||
tenantEnabledCollectionSlugs,
|
||||
tenantEnabledGlobalSlugs,
|
||||
}: AddFilterOptionsToFieldsArgs) {
|
||||
fields.forEach((field) => {
|
||||
if (field.type === 'relationship') {
|
||||
/**
|
||||
* Adjusts relationship fields to filter by tenant
|
||||
* and ensures relationTo cannot be a tenant global collection
|
||||
*/
|
||||
if (typeof field.relationTo === 'string') {
|
||||
if (tenantEnabledGlobalSlugs.includes(field.relationTo)) {
|
||||
throw new Error(
|
||||
`The collection ${field.relationTo} is a global collection and cannot be related to a tenant enabled collection.`,
|
||||
)
|
||||
}
|
||||
if (tenantEnabledCollectionSlugs.includes(field.relationTo)) {
|
||||
addFilter(field, tenantEnabledCollectionSlugs)
|
||||
}
|
||||
} else {
|
||||
field.relationTo.map((relationTo) => {
|
||||
if (tenantEnabledGlobalSlugs.includes(relationTo)) {
|
||||
throw new Error(
|
||||
`The collection ${relationTo} is a global collection and cannot be related to a tenant enabled collection.`,
|
||||
)
|
||||
}
|
||||
if (tenantEnabledCollectionSlugs.includes(relationTo)) {
|
||||
addFilter(field, tenantEnabledCollectionSlugs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
field.type === 'row' ||
|
||||
field.type === 'array' ||
|
||||
field.type === 'collapsible' ||
|
||||
field.type === 'group'
|
||||
) {
|
||||
addFilterOptionsToFields({
|
||||
fields: field.fields,
|
||||
tenantEnabledCollectionSlugs,
|
||||
tenantEnabledGlobalSlugs,
|
||||
})
|
||||
}
|
||||
|
||||
if (field.type === 'blocks') {
|
||||
field.blocks.forEach((block) => {
|
||||
addFilterOptionsToFields({
|
||||
fields: block.fields,
|
||||
tenantEnabledCollectionSlugs,
|
||||
tenantEnabledGlobalSlugs,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (field.type === 'tabs') {
|
||||
field.tabs.forEach((tab) => {
|
||||
addFilterOptionsToFields({
|
||||
fields: tab.fields,
|
||||
tenantEnabledCollectionSlugs,
|
||||
tenantEnabledGlobalSlugs,
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function addFilter(field: RelationshipField, tenantEnabledCollectionSlugs: string[]) {
|
||||
// User specified filter
|
||||
const originalFilter = field.filterOptions
|
||||
field.filterOptions = async (args) => {
|
||||
const originalFilterResult =
|
||||
typeof originalFilter === 'function' ? await originalFilter(args) : (originalFilter ?? true)
|
||||
|
||||
// If the relationTo is not a tenant enabled collection, return early
|
||||
if (args.relationTo && !tenantEnabledCollectionSlugs.includes(args.relationTo)) {
|
||||
return originalFilterResult
|
||||
}
|
||||
|
||||
// If the original filtr returns false, return early
|
||||
if (originalFilterResult === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Custom tenant filter
|
||||
const tenantFilterResults = filterOptionsByTenant(args)
|
||||
|
||||
// If the tenant filter returns true, just use the original filter
|
||||
if (tenantFilterResults === true) {
|
||||
return originalFilterResult
|
||||
}
|
||||
|
||||
// If the original filter returns true, just use the tenant filter
|
||||
if (originalFilterResult === true) {
|
||||
return tenantFilterResults
|
||||
}
|
||||
|
||||
return {
|
||||
and: [originalFilterResult, tenantFilterResults],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Args = {
|
||||
tenantFieldName?: string
|
||||
} & FilterOptionsProps
|
||||
const filterOptionsByTenant = ({ req, tenantFieldName = 'tenant' }: Args) => {
|
||||
const selectedTenant = getTenantFromCookie(req.headers, req.payload.db.defaultIDType)
|
||||
if (!selectedTenant) {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
or: [
|
||||
// ie a related collection that doesn't have a tenant field
|
||||
{
|
||||
[tenantFieldName]: {
|
||||
exists: false,
|
||||
},
|
||||
},
|
||||
// related collections that have a tenant field
|
||||
{
|
||||
[tenantFieldName]: {
|
||||
equals: selectedTenant,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Where } from 'payload'
|
||||
|
||||
export function combineWhereConstraints(constraints: Array<Where>): Where {
|
||||
if (constraints.length === 0) {
|
||||
return {}
|
||||
}
|
||||
if (constraints.length === 1 && constraints[0]) {
|
||||
return constraints[0]
|
||||
}
|
||||
const andConstraint: Where = {
|
||||
and: [],
|
||||
}
|
||||
constraints.forEach((constraint) => {
|
||||
if (andConstraint.and && constraint && typeof constraint === 'object') {
|
||||
andConstraint.and.push(constraint)
|
||||
}
|
||||
})
|
||||
return andConstraint
|
||||
}
|
||||
11
packages/plugin-multi-tenant/src/utilities/extractID.ts
Normal file
11
packages/plugin-multi-tenant/src/utilities/extractID.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Tenant } from '../types.js'
|
||||
|
||||
export const extractID = <IDType extends number | string>(
|
||||
objectOrID: IDType | Tenant<IDType>,
|
||||
): IDType => {
|
||||
if (typeof objectOrID === 'string' || typeof objectOrID === 'number') {
|
||||
return objectOrID
|
||||
}
|
||||
|
||||
return objectOrID.id
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { headers as getHeaders } from 'next/headers.js'
|
||||
|
||||
import { SELECT_ALL } from '../constants.js'
|
||||
import { getTenantFromCookie } from './getTenantFromCookie.js'
|
||||
|
||||
type Args = {
|
||||
docID?: number | string
|
||||
payload: Payload
|
||||
slug: string
|
||||
tenantFieldName: string
|
||||
view: 'edit' | 'list'
|
||||
}
|
||||
export async function getGlobalViewRedirect({
|
||||
slug,
|
||||
docID,
|
||||
payload,
|
||||
tenantFieldName,
|
||||
view,
|
||||
}: Args): Promise<string | void> {
|
||||
const headers = await getHeaders()
|
||||
const tenant = getTenantFromCookie(headers, payload.db.defaultIDType)
|
||||
let redirectRoute
|
||||
|
||||
if (tenant) {
|
||||
try {
|
||||
const { docs } = await payload.find({
|
||||
collection: slug,
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
where:
|
||||
tenant === SELECT_ALL
|
||||
? {}
|
||||
: {
|
||||
[tenantFieldName]: {
|
||||
equals: tenant,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const tenantDocID = docs?.[0]?.id
|
||||
|
||||
if (view === 'edit') {
|
||||
if (docID && !tenantDocID) {
|
||||
// viewing a document with an id but does not match the selected tenant, redirect to create route
|
||||
redirectRoute = `${payload.config.routes.admin}/collections/${slug}/create`
|
||||
} else if (tenantDocID && docID !== tenantDocID) {
|
||||
// tenant document already exists but does not match current route doc ID, redirect to matching tenant doc
|
||||
redirectRoute = `${payload.config.routes.admin}/collections/${slug}/${tenantDocID}`
|
||||
}
|
||||
} else if (view === 'list') {
|
||||
if (tenantDocID) {
|
||||
// tenant document exists, redirect to edit view
|
||||
redirectRoute = `${payload.config.routes.admin}/collections/${slug}/${tenantDocID}`
|
||||
} else {
|
||||
// tenant document does not exist, redirect to create route
|
||||
redirectRoute = `${payload.config.routes.admin}/collections/${slug}/create`
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
payload.logger.error(
|
||||
e,
|
||||
`${typeof e === 'object' && e && 'message' in e ? `e?.message - ` : ''}Multi Tenant Redirect Error`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return redirectRoute
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import type { UserWithTenantsField } from '../types.js'
|
||||
|
||||
import { getUserTenantIDs } from './getUserTenantIDs.js'
|
||||
|
||||
type Args = {
|
||||
fieldName: string
|
||||
user: UserWithTenantsField
|
||||
}
|
||||
export function getTenantAccess({ fieldName, user }: Args): Where {
|
||||
const userAssignedTenantIDs = getUserTenantIDs(user)
|
||||
|
||||
return {
|
||||
[fieldName]: {
|
||||
in: userAssignedTenantIDs || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { parseCookies } from 'payload'
|
||||
|
||||
/**
|
||||
* A function that takes request headers and an idType and returns the current tenant ID from the cookie
|
||||
*
|
||||
* @param headers Headers, usually derived from req.headers or next/headers
|
||||
* @param idType can be 'number' | 'text', usually derived from payload.db.defaultIDType
|
||||
* @returns string | number | null
|
||||
*/
|
||||
export function getTenantFromCookie(
|
||||
headers: Headers,
|
||||
idType: 'number' | 'text',
|
||||
): null | number | string {
|
||||
const cookies = parseCookies(headers)
|
||||
const selectedTenant = cookies.get('payload-tenant') || null
|
||||
return selectedTenant ? (idType === 'number' ? parseInt(selectedTenant) : selectedTenant) : null
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PayloadRequest, Where } from 'payload'
|
||||
|
||||
import { SELECT_ALL } from '../constants.js'
|
||||
import { getTenantFromCookie } from './getTenantFromCookie.js'
|
||||
|
||||
type Args = {
|
||||
req: PayloadRequest
|
||||
tenantFieldName: string
|
||||
}
|
||||
export const getTenantListFilter = ({ req, tenantFieldName }: Args): null | Where => {
|
||||
const selectedTenant = getTenantFromCookie(req.headers, req.payload.db.defaultIDType)
|
||||
|
||||
if (selectedTenant === SELECT_ALL) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
[tenantFieldName]: {
|
||||
equals: selectedTenant,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Tenant, UserWithTenantsField } from '../types.js'
|
||||
|
||||
import { extractID } from './extractID.js'
|
||||
|
||||
/**
|
||||
* Returns array of all tenant IDs assigned to a user
|
||||
*
|
||||
* @param user - User object with tenants field
|
||||
*/
|
||||
export const getUserTenantIDs = <IDType extends number | string>(
|
||||
user: null | UserWithTenantsField,
|
||||
): IDType[] => {
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
return (
|
||||
user?.tenants?.reduce<IDType[]>((acc, { tenant }) => {
|
||||
if (tenant) {
|
||||
acc.push(extractID<IDType>(tenant as Tenant<IDType>))
|
||||
}
|
||||
|
||||
return acc
|
||||
}, []) || []
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Access, AccessArgs, AccessResult, User } from 'payload'
|
||||
|
||||
import type { MultiTenantPluginConfig, UserWithTenantsField } from '../types.js'
|
||||
|
||||
import { combineWhereConstraints } from './combineWhereConstraints.js'
|
||||
import { getTenantAccess } from './getTenantAccess.js'
|
||||
|
||||
type Args<ConfigType> = {
|
||||
accessFunction?: Access
|
||||
fieldName: string
|
||||
userHasAccessToAllTenants: Required<
|
||||
MultiTenantPluginConfig<ConfigType>
|
||||
>['userHasAccessToAllTenants']
|
||||
}
|
||||
export const withTenantAccess =
|
||||
<ConfigType>({ accessFunction, fieldName, userHasAccessToAllTenants }: Args<ConfigType>) =>
|
||||
async (args: AccessArgs): Promise<AccessResult> => {
|
||||
const constraints = []
|
||||
const accessFn =
|
||||
typeof accessFunction === 'function'
|
||||
? accessFunction
|
||||
: ({ req }: AccessArgs): AccessResult => Boolean(req.user)
|
||||
const accessResult: AccessResult = await accessFn(args)
|
||||
|
||||
if (accessResult === false) {
|
||||
return false
|
||||
} else if (accessResult && typeof accessResult === 'object') {
|
||||
constraints.push(accessResult)
|
||||
}
|
||||
|
||||
if (
|
||||
args.req.user &&
|
||||
!userHasAccessToAllTenants(
|
||||
args.req.user as ConfigType extends { user: User } ? ConfigType['user'] : User,
|
||||
)
|
||||
) {
|
||||
constraints.push(
|
||||
getTenantAccess({
|
||||
fieldName,
|
||||
user: args.req.user as UserWithTenantsField,
|
||||
}),
|
||||
)
|
||||
return combineWhereConstraints(constraints)
|
||||
}
|
||||
|
||||
return accessResult
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BaseListFilter, Where } from 'payload'
|
||||
|
||||
import { getTenantListFilter } from './getTenantListFilter.js'
|
||||
|
||||
type Args = {
|
||||
baseListFilter?: BaseListFilter
|
||||
tenantFieldName: string
|
||||
}
|
||||
/**
|
||||
* Combines a base list filter with a tenant list filter
|
||||
*
|
||||
* Combines where constraints inside of an AND operator
|
||||
*/
|
||||
export const withTenantListFilter =
|
||||
({ baseListFilter, tenantFieldName }: Args): BaseListFilter =>
|
||||
async (args) => {
|
||||
const filterConstraints = []
|
||||
|
||||
if (typeof baseListFilter === 'function') {
|
||||
const baseListFilterResult = await baseListFilter(args)
|
||||
|
||||
if (baseListFilterResult) {
|
||||
filterConstraints.push(baseListFilterResult)
|
||||
}
|
||||
}
|
||||
|
||||
const tenantListFilter = getTenantListFilter({
|
||||
req: args.req,
|
||||
tenantFieldName,
|
||||
})
|
||||
|
||||
if (tenantListFilter) {
|
||||
filterConstraints.push(tenantListFilter)
|
||||
}
|
||||
|
||||
if (filterConstraints.length) {
|
||||
const combinedWhere: Where = { and: [] }
|
||||
filterConstraints.forEach((constraint) => {
|
||||
if (combinedWhere.and && constraint && typeof constraint === 'object') {
|
||||
combinedWhere.and.push(constraint)
|
||||
}
|
||||
})
|
||||
return combinedWhere
|
||||
}
|
||||
|
||||
// Access control will take it from here
|
||||
return null
|
||||
}
|
||||
4
packages/plugin-multi-tenant/tsconfig.json
Normal file
4
packages/plugin-multi-tenant/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"references": [{ "path": "../payload" }, { "path": "../ui" }]
|
||||
}
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -1028,6 +1028,22 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../payload
|
||||
|
||||
packages/plugin-multi-tenant:
|
||||
dependencies:
|
||||
next:
|
||||
specifier: ^15.0.3
|
||||
version: 15.1.3(@opentelemetry/api@1.9.0)(@playwright/test@1.48.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.0.0-beta-df7b47d-20241124)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(sass@1.77.4)
|
||||
devDependencies:
|
||||
'@payloadcms/eslint-config':
|
||||
specifier: workspace:*
|
||||
version: link:../eslint-config
|
||||
'@payloadcms/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../ui
|
||||
payload:
|
||||
specifier: workspace:*
|
||||
version: link:../payload
|
||||
|
||||
packages/plugin-nested-docs:
|
||||
devDependencies:
|
||||
'@payloadcms/eslint-config':
|
||||
@@ -1658,6 +1674,9 @@ importers:
|
||||
'@payloadcms/plugin-form-builder':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plugin-form-builder
|
||||
'@payloadcms/plugin-multi-tenant':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plugin-multi-tenant
|
||||
'@payloadcms/plugin-nested-docs':
|
||||
specifier: workspace:*
|
||||
version: link:../packages/plugin-nested-docs
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
* There is no way currently to have lint-staged ignore the templates directory.
|
||||
*/
|
||||
|
||||
import type { DbType, StorageAdapterType } from 'packages/create-payload-app/src/types.js'
|
||||
|
||||
import chalk from 'chalk'
|
||||
import { execSync } from 'child_process'
|
||||
import { configurePayloadConfig } from 'create-payload-app/lib/configure-payload-config.js'
|
||||
@@ -20,6 +18,8 @@ import * as fs from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
|
||||
import type { DbType, StorageAdapterType } from '../packages/create-payload-app/src/types.js'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ export const packagePublishList = [
|
||||
'plugin-cloud',
|
||||
'plugin-cloud-storage',
|
||||
'plugin-form-builder',
|
||||
// 'plugin-multi-tenant',
|
||||
'plugin-nested-docs',
|
||||
'plugin-redirects',
|
||||
'plugin-search',
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@payloadcms/payload-cloud": "workspace:*",
|
||||
"@payloadcms/plugin-cloud-storage": "workspace:*",
|
||||
"@payloadcms/plugin-form-builder": "workspace:*",
|
||||
"@payloadcms/plugin-multi-tenant": "workspace:*",
|
||||
"@payloadcms/plugin-nested-docs": "workspace:*",
|
||||
"@payloadcms/plugin-redirects": "workspace:*",
|
||||
"@payloadcms/plugin-search": "workspace:*",
|
||||
|
||||
18
test/plugin-multi-tenant/collections/Links.ts
Normal file
18
test/plugin-multi-tenant/collections/Links.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const LinksCollection: CollectionConfig = {
|
||||
slug: 'links',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
}
|
||||
37
test/plugin-multi-tenant/collections/Posts.ts
Normal file
37
test/plugin-multi-tenant/collections/Posts.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { postsSlug } from '../shared.js'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: postsSlug,
|
||||
labels: {
|
||||
singular: 'Post',
|
||||
plural: 'Posts',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
label: 'Excerpt',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'slug',
|
||||
localized: true,
|
||||
},
|
||||
{
|
||||
name: 'relatedLinks',
|
||||
relationTo: 'links',
|
||||
type: 'relationship',
|
||||
},
|
||||
],
|
||||
}
|
||||
32
test/plugin-multi-tenant/collections/Tenants.ts
Normal file
32
test/plugin-multi-tenant/collections/Tenants.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { tenantsSlug } from '../shared.js'
|
||||
|
||||
export const Tenants: CollectionConfig = {
|
||||
slug: tenantsSlug,
|
||||
labels: {
|
||||
singular: 'Tenant',
|
||||
plural: 'Tenants',
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'domain',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
34
test/plugin-multi-tenant/collections/Users.ts
Normal file
34
test/plugin-multi-tenant/collections/Users.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { usersSlug } from '../shared.js'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: usersSlug,
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
// Email added by default
|
||||
// Add more fields as needed
|
||||
{
|
||||
type: 'select',
|
||||
name: 'roles',
|
||||
hasMany: true,
|
||||
options: [
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
},
|
||||
{
|
||||
label: 'User',
|
||||
value: 'user',
|
||||
},
|
||||
],
|
||||
saveToJWT: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user