diff --git a/docs/plugins/multi-tenant.mdx b/docs/plugins/multi-tenant.mdx new file mode 100644 index 000000000..88e98519f --- /dev/null +++ b/docs/plugins/multi-tenant.mdx @@ -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 +--- + +[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-multi-tenants)](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. + + + + 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. + + +## 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 = { + /** + * 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({ + 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: '(?.*)', + }, + ], + }, + ]; +} +``` + + +## 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. diff --git a/examples/multi-tenant/README.md b/examples/multi-tenant/README.md index 94539b6a5..7e497db37 100644 --- a/examples/multi-tenant/README.md +++ b/examples/multi-tenant/README.md @@ -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 diff --git a/examples/multi-tenant/next.config.mjs b/examples/multi-tenant/next.config.mjs index 7ec4bd836..f6901cfd0 100644 --- a/examples/multi-tenant/next.config.mjs +++ b/examples/multi-tenant/next.config.mjs @@ -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: '(?.*)', + }, + ], + }, + ] + }, } export default withPayload(nextConfig) diff --git a/examples/multi-tenant/package.json b/examples/multi-tenant/package.json index 5649c3b97..4c9a5ae70 100644 --- a/examples/multi-tenant/package.json +++ b/examples/multi-tenant/package.json @@ -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", diff --git a/examples/multi-tenant/pnpm-lock.yaml b/examples/multi-tenant/pnpm-lock.yaml index f2f6294ae..aacbbb99c 100644 --- a/examples/multi-tenant/pnpm-lock.yaml +++ b/examples/multi-tenant/pnpm-lock.yaml @@ -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: {} diff --git a/examples/multi-tenant/src/access/isSuperAdmin.ts b/examples/multi-tenant/src/access/isSuperAdmin.ts index b5c748eb8..f449243da 100644 --- a/examples/multi-tenant/src/access/isSuperAdmin.ts +++ b/examples/multi-tenant/src/access/isSuperAdmin.ts @@ -1,8 +1,10 @@ import type { Access } from 'payload' +import { User } from '../payload-types' -export const isSuperAdmin: Access = ({ req }) => { - if (!req?.user) { - return false - } - return Boolean(req.user.roles?.includes('super-admin')) +export const isSuperAdminAccess: Access = ({ req }): boolean => { + return isSuperAdmin(req.user) +} + +export const isSuperAdmin = (user: User | null): boolean => { + return Boolean(user?.roles?.includes('super-admin')) } diff --git a/examples/multi-tenant/src/app/(app)/page.tsx b/examples/multi-tenant/src/app/(app)/page.tsx new file mode 100644 index 000000000..c772fd5a1 --- /dev/null +++ b/examples/multi-tenant/src/app/(app)/page.tsx @@ -0,0 +1,30 @@ +export default async ({ params: paramsPromise }: { params: Promise<{ slug: string[] }> }) => { + return ( +
+

Multi-Tenant Example

+

+ This multi-tenant example allows you to explore multi-tenancy with domains and with slugs. +

+ +

Domains

+

When you visit a tenant by domain, the domain is used to determine the tenant.

+

+ For example, visiting{' '} + + http://gold.localhost.com:3000/tenant-domains/login + {' '} + will show the tenant with the domain "gold.localhost.com". +

+ +

Slugs

+

When you visit a tenant by slug, the slug is used to determine the tenant.

+

+ For example, visiting{' '} + + http://localhost:3000/tenant-slugs/silver/login + {' '} + will show the tenant with the slug "silver". +

+
+ ) +} diff --git a/examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/[...slug]/page.tsx b/examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/[...slug]/page.tsx new file mode 100644 index 000000000..5900db524 --- /dev/null +++ b/examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/[...slug]/page.tsx @@ -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 +} diff --git a/examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/login/page.tsx b/examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/login/page.tsx new file mode 100644 index 000000000..cab55e42a --- /dev/null +++ b/examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/login/page.tsx @@ -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 }) { + const params = await paramsPromise + + return +} diff --git a/examples/multi-tenant/src/app/(app)/[tenant]/page.tsx b/examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/page.tsx similarity index 100% rename from examples/multi-tenant/src/app/(app)/[tenant]/page.tsx rename to examples/multi-tenant/src/app/(app)/tenant-domains/[tenant]/page.tsx diff --git a/examples/multi-tenant/src/app/(app)/[tenant]/[...slug]/page.tsx b/examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/[...slug]/page.tsx similarity index 64% rename from examples/multi-tenant/src/app/(app)/[tenant]/[...slug]/page.tsx rename to examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/[...slug]/page.tsx index 32bb33277..c1855dd1a 100644 --- a/examples/multi-tenant/src/app/(app)/[tenant]/[...slug]/page.tsx +++ b/examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/[...slug]/page.tsx @@ -6,7 +6,7 @@ import { notFound, redirect } from 'next/navigation' import { getPayload } from 'payload' import React from 'react' -import { RenderPage } from '../../../components/RenderPage' +import { RenderPage } from '../../../../components/RenderPage' // eslint-disable-next-line no-restricted-exports export default async function Page({ @@ -20,25 +20,34 @@ export default async function Page({ const payload = await getPayload({ config: configPromise }) const { user } = await payload.auth({ headers }) - const tenantsQuery = await payload.find({ - collection: 'tenants', - overrideAccess: false, - user, - where: { - slug: { - equals: params.tenant, - }, - }, - }) - const slug = params?.slug - // If no tenant is found, the user does not have access - // Show the login view - if (tenantsQuery.docs.length === 0) { + try { + const tenantsQuery = await payload.find({ + collection: 'tenants', + overrideAccess: false, + user, + where: { + slug: { + equals: params.tenant, + }, + }, + }) + // If no tenant is found, the user does not have access + // Show the login view + if (tenantsQuery.docs.length === 0) { + redirect( + `/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent( + `/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`, + )}`, + ) + } + } catch (e) { + // If the query fails, it means the user did not have access to query on the slug field + // Show the login view redirect( - `/${params.tenant}/login?redirect=${encodeURIComponent( - `/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`, + `/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent( + `/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`, )}`, ) } diff --git a/examples/multi-tenant/src/app/(app)/[tenant]/login/page.tsx b/examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/login/page.tsx similarity index 82% rename from examples/multi-tenant/src/app/(app)/[tenant]/login/page.tsx rename to examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/login/page.tsx index 8416f2133..a7bda1839 100644 --- a/examples/multi-tenant/src/app/(app)/[tenant]/login/page.tsx +++ b/examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/login/page.tsx @@ -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 diff --git a/examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/page.tsx b/examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/page.tsx new file mode 100644 index 000000000..6e49cf638 --- /dev/null +++ b/examples/multi-tenant/src/app/(app)/tenant-slugs/[tenant]/page.tsx @@ -0,0 +1,3 @@ +import Page from './[...slug]/page' + +export default Page diff --git a/examples/multi-tenant/src/app/(payload)/admin/importMap.js b/examples/multi-tenant/src/app/(payload)/admin/importMap.js index da787ce12..5273adf95 100644 --- a/examples/multi-tenant/src/app/(payload)/admin/importMap.js +++ b/examples/multi-tenant/src/app/(payload)/admin/importMap.js @@ -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 } diff --git a/examples/multi-tenant/src/app/components/Login/client.page.tsx b/examples/multi-tenant/src/app/components/Login/client.page.tsx index e12e1c1a6..4092777ed 100644 --- a/examples/multi-tenant/src/app/components/Login/client.page.tsx +++ b/examples/multi-tenant/src/app/components/Login/client.page.tsx @@ -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(null) const passwordRef = React.useRef(null) const router = useRouter() - const routeParams = useParams() const searchParams = useSearchParams() const handleSubmit = async (e: FormEvent) => { @@ -23,20 +27,18 @@ export const Login = ({ tenantSlug }: Props) => { if (!usernameRef?.current?.value || !passwordRef?.current?.value) { return } - const actionRes = await fetch( - `${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/external-users/login`, - { - body: JSON.stringify({ - password: passwordRef.current.value, - tenantSlug, - username: usernameRef.current.value, - }), - headers: { - 'content-type': 'application/json', - }, - method: 'post', + const actionRes = await fetch('/api/users/external-users/login', { + body: JSON.stringify({ + password: passwordRef.current.value, + tenantSlug, + tenantDomain, + username: usernameRef.current.value, + }), + headers: { + 'content-type': 'application/json', }, - ) + method: 'post', + }) const json = await actionRes.json() if (actionRes.status === 200 && json.user) { @@ -45,7 +47,11 @@ export const Login = ({ tenantSlug }: Props) => { router.push(redirectTo) return } else { - router.push(`/${routeParams.tenant}`) + if (tenantDomain) { + router.push('/tenant-domains') + } else { + router.push(`/tenant-slugs/${tenantSlug}`) + } } } else if (actionRes.status === 400 && json?.errors?.[0]?.message) { window.alert(json.errors[0].message) diff --git a/examples/multi-tenant/src/app/components/RenderPage/index.tsx b/examples/multi-tenant/src/app/components/RenderPage/index.tsx index 68e532f8b..2bc095f5e 100644 --- a/examples/multi-tenant/src/app/components/RenderPage/index.tsx +++ b/examples/multi-tenant/src/app/components/RenderPage/index.tsx @@ -5,7 +5,11 @@ import React from 'react' export const RenderPage = ({ data }: { data: Page }) => { return ( +
+ +

Here you can decide how you would like to render the page data!

+ {JSON.stringify(data)}
) diff --git a/examples/multi-tenant/src/collections/Pages/access/baseListFilter.ts b/examples/multi-tenant/src/collections/Pages/access/baseListFilter.ts deleted file mode 100644 index 063967882..000000000 --- a/examples/multi-tenant/src/collections/Pages/access/baseListFilter.ts +++ /dev/null @@ -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 -} diff --git a/examples/multi-tenant/src/collections/Pages/access/byTenant.ts b/examples/multi-tenant/src/collections/Pages/access/byTenant.ts deleted file mode 100644 index df704c0b3..000000000 --- a/examples/multi-tenant/src/collections/Pages/access/byTenant.ts +++ /dev/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 - ) -} diff --git a/examples/multi-tenant/src/collections/Pages/access/readAccess.ts b/examples/multi-tenant/src/collections/Pages/access/readAccess.ts deleted file mode 100644 index fc6bd9e79..000000000 --- a/examples/multi-tenant/src/collections/Pages/access/readAccess.ts +++ /dev/null @@ -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 -} diff --git a/examples/multi-tenant/src/collections/Pages/access/superAdminOrTenantAdmin.ts b/examples/multi-tenant/src/collections/Pages/access/superAdminOrTenantAdmin.ts new file mode 100644 index 000000000..f60af75c8 --- /dev/null +++ b/examples/multi-tenant/src/collections/Pages/access/superAdminOrTenantAdmin.ts @@ -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'), + }, + } +} diff --git a/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts b/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts index 3c11e4371..55826874f 100644 --- a/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts +++ b/examples/multi-tenant/src/collections/Pages/hooks/ensureUniqueSlug.ts @@ -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) { diff --git a/examples/multi-tenant/src/collections/Pages/index.ts b/examples/multi-tenant/src/collections/Pages/index.ts index 6aad6680a..0ff7d8956 100644 --- a/examples/multi-tenant/src/collections/Pages/index.ts +++ b/examples/multi-tenant/src/collections/Pages/index.ts @@ -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, ], } diff --git a/examples/multi-tenant/src/collections/Tenants/access/byTenant.ts b/examples/multi-tenant/src/collections/Tenants/access/byTenant.ts index b4ebb5fd9..05fc99a1e 100644 --- a/examples/multi-tenant/src/collections/Tenants/access/byTenant.ts +++ b/examples/multi-tenant/src/collections/Tenants/access/byTenant.ts @@ -1,53 +1,26 @@ import type { Access } from 'payload' import { isSuperAdmin } from '../../../access/isSuperAdmin' -import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs' export const filterByTenantRead: Access = (args) => { - const req = args.req - - // Super admin can read all - if (isSuperAdmin(args)) { - return true - } - - const tenantIDs = getTenantAccessIDs(req.user) - // Allow public tenants to be read by anyone - const publicConstraint = { - public: { - equals: true, - }, - } - - // If a user has tenant ID access, - // return constraint to allow them to read those tenants - if (tenantIDs.length) { + if (!args.req.user) { return { - or: [ - publicConstraint, - { - id: { - in: tenantIDs, - }, - }, - ], + allowPublicRead: { + equals: true, + }, } } - return publicConstraint + return true } -export const canMutateTenant: Access = (args) => { - const req = args.req - const superAdmin = isSuperAdmin(args) - +export const canMutateTenant: Access = ({ req }) => { if (!req.user) { return false } - // super admins can mutate pages for any tenant - if (superAdmin) { + if (isSuperAdmin(req.user)) { return true } diff --git a/examples/multi-tenant/src/collections/Tenants/access/read.ts b/examples/multi-tenant/src/collections/Tenants/access/read.ts deleted file mode 100644 index 9900c3897..000000000 --- a/examples/multi-tenant/src/collections/Tenants/access/read.ts +++ /dev/null @@ -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 -} diff --git a/examples/multi-tenant/src/collections/Tenants/access/updateAndDelete.ts b/examples/multi-tenant/src/collections/Tenants/access/updateAndDelete.ts new file mode 100644 index 000000000..e80340296 --- /dev/null +++ b/examples/multi-tenant/src/collections/Tenants/access/updateAndDelete.ts @@ -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'), + }, + } +} diff --git a/examples/multi-tenant/src/collections/Tenants/index.ts b/examples/multi-tenant/src/collections/Tenants/index.ts index 752f8de55..f1b614eb2 100644 --- a/examples/multi-tenant/src/collections/Tenants/index.ts +++ b/examples/multi-tenant/src/collections/Tenants/index.ts @@ -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, diff --git a/examples/multi-tenant/src/collections/Users/access/create.ts b/examples/multi-tenant/src/collections/Users/access/create.ts index d7fcd286d..bc9fbdca2 100644 --- a/examples/multi-tenant/src/collections/Users/access/create.ts +++ b/examples/multi-tenant/src/collections/Users/access/create.ts @@ -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 = (args) => { - const { req } = args +export const createAccess: Access = ({ 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 } diff --git a/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts b/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts index e6a15710f..980a8b1d3 100644 --- a/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts +++ b/examples/multi-tenant/src/collections/Users/access/isAccessingSelf.ts @@ -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 } diff --git a/examples/multi-tenant/src/collections/Users/access/isSuperAdminOrSelf.ts b/examples/multi-tenant/src/collections/Users/access/isSuperAdminOrSelf.ts deleted file mode 100644 index 8cb36bc30..000000000 --- a/examples/multi-tenant/src/collections/Users/access/isSuperAdminOrSelf.ts +++ /dev/null @@ -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) diff --git a/examples/multi-tenant/src/collections/Users/access/read.ts b/examples/multi-tenant/src/collections/Users/access/read.ts index b4aa7b9ba..c81e83600 100644 --- a/examples/multi-tenant/src/collections/Users/access/read.ts +++ b/examples/multi-tenant/src/collections/Users/access/read.ts @@ -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 = (args) => { - const { req } = args +export const readAccess: Access = ({ 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 = (args) => { return true } - const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user) - return { - 'tenants.tenant': { - in: adminTenantAccessIDs, - }, + or: [ + { + id: { + equals: req.user.id, + }, + }, + { + 'tenants.tenant': { + in: adminTenantAccessIDs, + }, + }, + ], } as Where } diff --git a/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts b/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts index 6f6ef74b1..808f1db19 100644 --- a/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts +++ b/examples/multi-tenant/src/collections/Users/access/updateAndDelete.ts @@ -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'), }, } } diff --git a/examples/multi-tenant/src/collections/Users/endpoints/externalUsersLogin.ts b/examples/multi-tenant/src/collections/Users/endpoints/externalUsersLogin.ts index b34c4d0d2..c6ccde3a0 100644 --- a/examples/multi-tenant/src/collections/Users/endpoints/externalUsersLogin.ts +++ b/examples/multi-tenant/src/collections/Users/endpoints/externalUsersLogin.ts @@ -16,7 +16,7 @@ export const externalUsersLogin: Endpoint = { } catch (error) { // swallow error, data is already empty object } - const { password, tenantSlug, username } = data + const { password, tenantSlug, tenantDomain, username } = data if (!username || !password) { throw new APIError('Username and Password are required for login.', 400, null, true) @@ -25,11 +25,17 @@ export const externalUsersLogin: Endpoint = { const fullTenant = ( await req.payload.find({ collection: 'tenants', - where: { - slug: { - equals: tenantSlug, - }, - }, + where: tenantDomain + ? { + domain: { + equals: tenantDomain, + }, + } + : { + slug: { + equals: tenantSlug, + }, + }, }) ).docs[0] diff --git a/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts b/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts index d53ac2b67..7c5d8ae5e 100644 --- a/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts +++ b/examples/multi-tenant/src/collections/Users/hooks/ensureUniqueUsername.ts @@ -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) { diff --git a/examples/multi-tenant/src/collections/Users/hooks/setCookieBasedOnDomain.ts b/examples/multi-tenant/src/collections/Users/hooks/setCookieBasedOnDomain.ts index f34176bc7..229d0500a 100644 --- a/examples/multi-tenant/src/collections/Users/hooks/setCookieBasedOnDomain.ts +++ b/examples/multi-tenant/src/collections/Users/hooks/setCookieBasedOnDomain.ts @@ -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'), }, }, }) diff --git a/examples/multi-tenant/src/collections/Users/index.ts b/examples/multi-tenant/src/collections/Users/index.ts index 8ce9402fa..022c5ed34 100644 --- a/examples/multi-tenant/src/collections/Users/index.ts +++ b/examples/multi-tenant/src/collections/Users/index.ts @@ -5,7 +5,24 @@ import { readAccess } from './access/read' import { updateAndDeleteAccess } from './access/updateAndDelete' import { externalUsersLogin } from './endpoints/externalUsersLogin' import { ensureUniqueUsername } from './hooks/ensureUniqueUsername' -// import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain' +import { isSuperAdmin } from '@/access/isSuperAdmin' +import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain' +import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields' + +const defaultTenantArrayField = tenantsArrayField({ + arrayFieldAccess: {}, + tenantFieldAccess: {}, + rowFields: [ + { + name: 'roles', + type: 'select', + defaultValue: ['tenant-viewer'], + hasMany: true, + options: ['tenant-admin', 'tenant-viewer'], + required: true, + }, + ], +}) const Users: CollectionConfig = { slug: 'users', @@ -22,34 +39,19 @@ const Users: CollectionConfig = { endpoints: [externalUsersLogin], fields: [ { + admin: { + position: 'sidebar', + }, name: 'roles', type: 'select', defaultValue: ['user'], hasMany: true, options: ['super-admin', 'user'], - }, - { - name: 'tenants', - type: 'array', - fields: [ - { - name: 'tenant', - type: 'relationship', - index: true, - relationTo: 'tenants', - required: true, - saveToJWT: true, + access: { + update: ({ req }) => { + return isSuperAdmin(req.user) }, - { - name: 'roles', - type: 'select', - defaultValue: ['tenant-viewer'], - hasMany: true, - options: ['tenant-admin', 'tenant-viewer'], - required: true, - }, - ], - saveToJWT: true, + }, }, { name: 'username', @@ -59,15 +61,21 @@ const Users: CollectionConfig = { }, index: true, }, + { + ...defaultTenantArrayField, + admin: { + ...(defaultTenantArrayField?.admin || {}), + position: 'sidebar', + }, + }, ], // The following hook sets a cookie based on the domain a user logs in from. // It checks the domain and matches it to a tenant in the system, then sets // a 'payload-tenant' cookie for that tenant. - // Uncomment this if you want to enable tenant-based cookie handling by domain. - // hooks: { - // afterLogin: [setCookieBasedOnDomain], - // }, + hooks: { + afterLogin: [setCookieBasedOnDomain], + }, } export default Users diff --git a/examples/multi-tenant/src/components/TenantSelector/index.client.tsx b/examples/multi-tenant/src/components/TenantSelector/index.client.tsx deleted file mode 100644 index c9b68c2fd..000000000 --- a/examples/multi-tenant/src/components/TenantSelector/index.client.tsx +++ /dev/null @@ -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() - const [options, setOptions] = React.useState([]) - - 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 ( -
- opt.value === initialCookie)?.value} - /> -
- ) - } - - return null -} diff --git a/examples/multi-tenant/src/components/TenantSelector/index.tsx b/examples/multi-tenant/src/components/TenantSelector/index.tsx deleted file mode 100644 index 933b0071b..000000000 --- a/examples/multi-tenant/src/components/TenantSelector/index.tsx +++ /dev/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 -} diff --git a/examples/multi-tenant/src/fields/TenantField/access/update.ts b/examples/multi-tenant/src/fields/TenantField/access/update.ts deleted file mode 100644 index 92852a299..000000000 --- a/examples/multi-tenant/src/fields/TenantField/access/update.ts +++ /dev/null @@ -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) -} diff --git a/examples/multi-tenant/src/fields/TenantField/components/Field.client.tsx b/examples/multi-tenant/src/fields/TenantField/components/Field.client.tsx deleted file mode 100644 index f159d0e6b..000000000 --- a/examples/multi-tenant/src/fields/TenantField/components/Field.client.tsx +++ /dev/null @@ -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 ( - - ) -} diff --git a/examples/multi-tenant/src/fields/TenantField/components/Field.tsx b/examples/multi-tenant/src/fields/TenantField/components/Field.tsx deleted file mode 100644 index 1644c2aa0..000000000 --- a/examples/multi-tenant/src/fields/TenantField/components/Field.tsx +++ /dev/null @@ -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 ( - - ) - } - - return null -} diff --git a/examples/multi-tenant/src/fields/TenantField/hooks/autofillTenant.ts b/examples/multi-tenant/src/fields/TenantField/hooks/autofillTenant.ts deleted file mode 100644 index 1bc0814fb..000000000 --- a/examples/multi-tenant/src/fields/TenantField/hooks/autofillTenant.ts +++ /dev/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 -} diff --git a/examples/multi-tenant/src/fields/TenantField/index.ts b/examples/multi-tenant/src/fields/TenantField/index.ts deleted file mode 100644 index 0716f980a..000000000 --- a/examples/multi-tenant/src/fields/TenantField/index.ts +++ /dev/null @@ -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, -} diff --git a/examples/multi-tenant/src/migrations/seed.ts b/examples/multi-tenant/src/migrations/seed.ts index 8e8c0ac5d..52e0db3a5 100644 --- a/examples/multi-tenant/src/migrations/seed.ts +++ b/examples/multi-tenant/src/migrations/seed.ts @@ -10,15 +10,12 @@ export async function up({ payload }: MigrateUpArgs): Promise { }, }) - // 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 { 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 { collection: 'tenants', data: { name: 'Tenant 3', - slug: 'tenant-3', - // domains: [{ domain: 'cbc.localhost.com:3000' }], + slug: 'bronze', + domain: 'bronze.localhost.com', }, }) diff --git a/examples/multi-tenant/src/mocks/emptyObject.js b/examples/multi-tenant/src/mocks/emptyObject.js deleted file mode 100644 index b1c6ea436..000000000 --- a/examples/multi-tenant/src/mocks/emptyObject.js +++ /dev/null @@ -1 +0,0 @@ -export default {} diff --git a/examples/multi-tenant/src/payload-types.ts b/examples/multi-tenant/src/payload-types.ts index 256ed40c2..896ceabea 100644 --- a/examples/multi-tenant/src/payload-types.ts +++ b/examples/multi-tenant/src/payload-types.ts @@ -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 { + tenant?: T; title?: T; slug?: T; - tenant?: T; updatedAt?: T; createdAt?: T; } @@ -187,6 +188,7 @@ export interface PagesSelect { */ export interface UsersSelect { roles?: T; + username?: T; tenants?: | T | { @@ -194,7 +196,6 @@ export interface UsersSelect { roles?: T; id?: T; }; - username?: T; updatedAt?: T; createdAt?: T; email?: T; @@ -211,8 +212,9 @@ export interface UsersSelect { */ export interface TenantsSelect { name?: T; + domain?: T; slug?: T; - public?: T; + allowPublicRead?: T; updatedAt?: T; createdAt?: T; } diff --git a/examples/multi-tenant/src/payload.config.ts b/examples/multi-tenant/src/payload.config.ts index 52b818c27..c7ce37f67 100644 --- a/examples/multi-tenant/src/payload.config.ts +++ b/examples/multi-tenant/src/payload.config.ts @@ -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({ + 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), + }), + ], }) diff --git a/examples/multi-tenant/src/utilities/getTenantAccessIDs.ts b/examples/multi-tenant/src/utilities/getTenantAccessIDs.ts deleted file mode 100644 index 37174831e..000000000 --- a/examples/multi-tenant/src/utilities/getTenantAccessIDs.ts +++ /dev/null @@ -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((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((acc, { roles, tenant }) => { - if (roles.includes('tenant-admin') && tenant) { - acc.push(extractID(tenant)) - } - return acc - }, []) || [] - ) -} diff --git a/examples/multi-tenant/src/utilities/getUserTenantIDs.ts b/examples/multi-tenant/src/utilities/getUserTenantIDs.ts new file mode 100644 index 000000000..50dd4e777 --- /dev/null +++ b/examples/multi-tenant/src/utilities/getUserTenantIDs.ts @@ -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[number]['roles'][number], +): Tenant['id'][] => { + if (!user) { + return [] + } + + return ( + user?.tenants?.reduce((acc, { roles, tenant }) => { + if (role && !roles.includes(role)) { + return acc + } + + if (tenant) { + acc.push(extractID(tenant)) + } + + return acc + }, []) || [] + ) +} diff --git a/examples/multi-tenant/src/utilities/isPayloadAdminPanel.ts b/examples/multi-tenant/src/utilities/isPayloadAdminPanel.ts deleted file mode 100644 index 71a0cfc19..000000000 --- a/examples/multi-tenant/src/utilities/isPayloadAdminPanel.ts +++ /dev/null @@ -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}`) - ) -} diff --git a/examples/multi-tenant/tsconfig.json b/examples/multi-tenant/tsconfig.json index fa9a3c238..6951906d8 100644 --- a/examples/multi-tenant/tsconfig.json +++ b/examples/multi-tenant/tsconfig.json @@ -40,7 +40,6 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "src/mocks/emptyObject.js" ], "exclude": [ "node_modules" diff --git a/package.json b/package.json index 4e11b1255..53975f0dd 100644 --- a/package.json +++ b/package.json @@ -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\"", diff --git a/packages/next/src/templates/Default/index.tsx b/packages/next/src/templates/Default/index.tsx index b410ee6a1..a08f72586 100644 --- a/packages/next/src/templates/Default/index.tsx +++ b/packages/next/src/templates/Default/index.tsx @@ -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 = ({ children, className, + collectionSlug, + docID, + globalSlug, i18n, locale, params, @@ -35,6 +42,7 @@ export const DefaultTemplate: React.FC = ({ searchParams, user, viewActions, + viewType, visibleEntities, }) => { const { @@ -50,6 +58,20 @@ export const DefaultTemplate: React.FC = ({ const serverProps = React.useMemo( () => ({ + collectionSlug, + docID, + globalSlug, + i18n, + locale, + params, + payload, + permissions, + searchParams, + user, + viewType, + visibleEntities, + }), + [ i18n, locale, params, @@ -58,8 +80,11 @@ export const DefaultTemplate: React.FC = ({ searchParams, user, visibleEntities, - }), - [i18n, locale, params, payload, permissions, searchParams, user, visibleEntities], + globalSlug, + collectionSlug, + docID, + viewType, + ], ) const { Actions } = React.useMemo<{ diff --git a/packages/next/src/views/Root/getViewFromConfig.ts b/packages/next/src/views/Root/getViewFromConfig.ts index 21380f956..8fdb45f0f 100644 --- a/packages/next/src/views/Root/getViewFromConfig.ts +++ b/packages/next/src/views/Root/getViewFromConfig.ts @@ -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) { diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index 0685e8e1c..681a27300 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -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' && ( " error introduced in React 19 // which this caused as soon as initPageResult.visibleEntities is passed in diff --git a/packages/payload/src/versions/payloadPackageList.ts b/packages/payload/src/versions/payloadPackageList.ts index cd45da1e0..93bec0026 100644 --- a/packages/payload/src/versions/payloadPackageList.ts +++ b/packages/payload/src/versions/payloadPackageList.ts @@ -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', diff --git a/packages/plugin-multi-tenant/.gitignore b/packages/plugin-multi-tenant/.gitignore new file mode 100644 index 000000000..4baaac85f --- /dev/null +++ b/packages/plugin-multi-tenant/.gitignore @@ -0,0 +1,7 @@ +node_modules +.env +dist +demo/uploads +build +.DS_Store +package-lock.json diff --git a/packages/plugin-multi-tenant/.prettierignore b/packages/plugin-multi-tenant/.prettierignore new file mode 100644 index 000000000..17883dc0e --- /dev/null +++ b/packages/plugin-multi-tenant/.prettierignore @@ -0,0 +1,12 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp +**/docs/** +tsconfig.json diff --git a/packages/plugin-multi-tenant/.swcrc b/packages/plugin-multi-tenant/.swcrc new file mode 100644 index 000000000..b4fb882ca --- /dev/null +++ b/packages/plugin-multi-tenant/.swcrc @@ -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" + } +} diff --git a/packages/plugin-multi-tenant/LICENSE.md b/packages/plugin-multi-tenant/LICENSE.md new file mode 100644 index 000000000..b31a68cbd --- /dev/null +++ b/packages/plugin-multi-tenant/LICENSE.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018-2024 Payload CMS, Inc. + +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. diff --git a/packages/plugin-multi-tenant/README.md b/packages/plugin-multi-tenant/README.md new file mode 100644 index 000000000..a9c4481bd --- /dev/null +++ b/packages/plugin-multi-tenant/README.md @@ -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 = { + /** + * 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', + }, + ], +} +``` diff --git a/packages/plugin-multi-tenant/eslint.config.js b/packages/plugin-multi-tenant/eslint.config.js new file mode 100644 index 000000000..f9d341be5 --- /dev/null +++ b/packages/plugin-multi-tenant/eslint.config.js @@ -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 diff --git a/packages/plugin-multi-tenant/package.json b/packages/plugin-multi-tenant/package.json new file mode 100644 index 000000000..f2b085d84 --- /dev/null +++ b/packages/plugin-multi-tenant/package.json @@ -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 (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" +} diff --git a/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts b/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts new file mode 100644 index 000000000..8ee0b9004 --- /dev/null +++ b/packages/plugin-multi-tenant/src/components/GlobalViewRedirect/index.ts @@ -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) + } + } +} diff --git a/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx new file mode 100644 index 000000000..d5b14a96b --- /dev/null +++ b/packages/plugin-multi-tenant/src/components/TenantField/index.client.tsx @@ -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({ 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 ( +
+
+ +
+
+
+ ) + } + + return null +} diff --git a/packages/plugin-multi-tenant/src/components/TenantField/index.scss b/packages/plugin-multi-tenant/src/components/TenantField/index.scss new file mode 100644 index 000000000..ed1f7ac20 --- /dev/null +++ b/packages/plugin-multi-tenant/src/components/TenantField/index.scss @@ -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); + } + } diff --git a/packages/plugin-multi-tenant/src/components/TenantSelector/index.client.tsx b/packages/plugin-multi-tenant/src/components/TenantSelector/index.client.tsx new file mode 100644 index 000000000..a262a1015 --- /dev/null +++ b/packages/plugin-multi-tenant/src/components/TenantSelector/index.client.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/examples/multi-tenant/src/components/TenantSelector/index.scss b/packages/plugin-multi-tenant/src/components/TenantSelector/index.scss similarity index 61% rename from examples/multi-tenant/src/components/TenantSelector/index.scss rename to packages/plugin-multi-tenant/src/components/TenantSelector/index.scss index 3f155e2a2..4c5685878 100644 --- a/examples/multi-tenant/src/components/TenantSelector/index.scss +++ b/packages/plugin-multi-tenant/src/components/TenantSelector/index.scss @@ -1,3 +1,4 @@ .tenant-selector { width: 100%; + margin-bottom: 2rem; } diff --git a/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx new file mode 100644 index 000000000..0c96caec9 --- /dev/null +++ b/packages/plugin-multi-tenant/src/components/TenantSelector/index.tsx @@ -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 +} diff --git a/packages/plugin-multi-tenant/src/constants.ts b/packages/plugin-multi-tenant/src/constants.ts new file mode 100644 index 000000000..910f5fac5 --- /dev/null +++ b/packages/plugin-multi-tenant/src/constants.ts @@ -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_' diff --git a/packages/plugin-multi-tenant/src/exports/client.ts b/packages/plugin-multi-tenant/src/exports/client.ts new file mode 100644 index 000000000..3dae41b4d --- /dev/null +++ b/packages/plugin-multi-tenant/src/exports/client.ts @@ -0,0 +1,2 @@ +export { TenantField } from '../components/TenantField/index.client.js' +export { TenantSelectionProvider } from '../providers/TenantSelectionProvider/index.js' diff --git a/packages/plugin-multi-tenant/src/exports/fields.ts b/packages/plugin-multi-tenant/src/exports/fields.ts new file mode 100644 index 000000000..7dd1ac447 --- /dev/null +++ b/packages/plugin-multi-tenant/src/exports/fields.ts @@ -0,0 +1,2 @@ +export { tenantField } from '../fields/tenantField/index.js' +export { tenantsArrayField } from '../fields/tenantsArrayField/index.js' diff --git a/packages/plugin-multi-tenant/src/exports/rsc.ts b/packages/plugin-multi-tenant/src/exports/rsc.ts new file mode 100644 index 000000000..e1d4bf264 --- /dev/null +++ b/packages/plugin-multi-tenant/src/exports/rsc.ts @@ -0,0 +1,2 @@ +export { GlobalViewRedirect } from '../components/GlobalViewRedirect/index.js' +export { TenantSelector } from '../components/TenantSelector/index.js' diff --git a/packages/plugin-multi-tenant/src/exports/types.ts b/packages/plugin-multi-tenant/src/exports/types.ts new file mode 100644 index 000000000..e8cbe97c5 --- /dev/null +++ b/packages/plugin-multi-tenant/src/exports/types.ts @@ -0,0 +1 @@ +export type { MultiTenantPluginConfig } from '../types.js' diff --git a/packages/plugin-multi-tenant/src/exports/utilities.ts b/packages/plugin-multi-tenant/src/exports/utilities.ts new file mode 100644 index 000000000..595e49481 --- /dev/null +++ b/packages/plugin-multi-tenant/src/exports/utilities.ts @@ -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' diff --git a/packages/plugin-multi-tenant/src/fields/tenantField/index.ts b/packages/plugin-multi-tenant/src/fields/tenantField/index.ts new file mode 100644 index 000000000..5ad7bab1a --- /dev/null +++ b/packages/plugin-multi-tenant/src/fields/tenantField/index.ts @@ -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, +}) diff --git a/packages/plugin-multi-tenant/src/fields/tenantsArrayField/index.ts b/packages/plugin-multi-tenant/src/fields/tenantsArrayField/index.ts new file mode 100644 index 000000000..083329208 --- /dev/null +++ b/packages/plugin-multi-tenant/src/fields/tenantsArrayField/index.ts @@ -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, +}) diff --git a/packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts b/packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts new file mode 100644 index 000000000..fb0c9db75 --- /dev/null +++ b/packages/plugin-multi-tenant/src/hooks/afterTenantDelete.ts @@ -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): CollectionAfterDeleteHook => + async ({ id, req }) => { + const currentTenantCookieID = getTenantFromCookie(req.headers, req.payload.db.defaultIDType) + if (currentTenantCookieID === id) { + const newHeaders = new Headers({ + 'Set-Cookie': generateCookie({ + 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[] = [] + 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 + + 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) + } diff --git a/packages/plugin-multi-tenant/src/index.ts b/packages/plugin-multi-tenant/src/index.ts new file mode 100644 index 000000000..b5e0913be --- /dev/null +++ b/packages/plugin-multi-tenant/src/index.ts @@ -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 = + (pluginConfig: MultiTenantPluginConfig) => + (incomingConfig: Config): Config => { + if (pluginConfig.enabled === false) { + return incomingConfig + } + + /** + * Set defaults + */ + const userHasAccessToAllTenants: Required< + MultiTenantPluginConfig + >['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 + } diff --git a/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx new file mode 100644 index 000000000..0e57a21d3 --- /dev/null +++ b/packages/plugin-multi-tenant/src/providers/TenantSelectionProvider/index.tsx @@ -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({ + options: [], + selectedTenantID: undefined, + setOptions: () => null, + setRefreshOnChange: () => null, + setTenant: () => null, +}) + +export const TenantSelectionProvider = ({ children }: { children: React.ReactNode }) => { + const [selectedTenantID, setSelectedTenantID] = React.useState( + undefined, + ) + const [tenantSelectionFrom, setTenantSelectionFrom] = React.useState< + 'cookie' | 'document' | undefined + >(undefined) + const [refreshOnChange, setRefreshOnChange] = React.useState(true) + const [options, setOptions] = React.useState([]) + + 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( + ({ 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 ( + + {children} + + ) +} + +export const useTenantSelection = () => React.useContext(Context) diff --git a/packages/plugin-multi-tenant/src/types.ts b/packages/plugin-multi-tenant/src/types.ts new file mode 100644 index 000000000..8b0bf9d47 --- /dev/null +++ b/packages/plugin-multi-tenant/src/types.ts @@ -0,0 +1,121 @@ +import type { ArrayField, CollectionSlug, Field, RelationshipField, User } from 'payload' + +export type MultiTenantPluginConfig = { + /** + * 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 = { + id: IDType + name: string +} + +export type UserWithTenantsField = { + tenants: { + tenant: number | string | Tenant + }[] +} & User diff --git a/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts new file mode 100644 index 000000000..19cd6b57f --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/addCollectionAccess.ts @@ -0,0 +1,53 @@ +import type { Access, CollectionConfig } from 'payload' + +import type { MultiTenantPluginConfig } from '../types.js' + +import { withTenantAccess } from './withTenantAccess.js' + +type AllAccessKeys = T[number] extends keyof Omit< + Required['access'], + 'admin' +> + ? keyof Omit['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 = { + collection: CollectionConfig + fieldName: string + userHasAccessToAllTenants: Required< + MultiTenantPluginConfig + >['userHasAccessToAllTenants'] +} + +/** + * Adds tenant access constraint to collection + */ +export const addCollectionAccess = ({ + collection, + fieldName, + userHasAccessToAllTenants, +}: Args): 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({ + accessFunction: collection.access?.[key], + fieldName, + userHasAccessToAllTenants, + }) + + return acc + }, {}) +} diff --git a/packages/plugin-multi-tenant/src/utilities/addFilterOptionsToFields.ts b/packages/plugin-multi-tenant/src/utilities/addFilterOptionsToFields.ts new file mode 100644 index 000000000..11dc282ea --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/addFilterOptionsToFields.ts @@ -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, + }, + }, + ], + } +} diff --git a/packages/plugin-multi-tenant/src/utilities/combineWhereConstraints.ts b/packages/plugin-multi-tenant/src/utilities/combineWhereConstraints.ts new file mode 100644 index 000000000..e04e03430 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/combineWhereConstraints.ts @@ -0,0 +1,19 @@ +import type { Where } from 'payload' + +export function combineWhereConstraints(constraints: Array): 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 +} diff --git a/packages/plugin-multi-tenant/src/utilities/extractID.ts b/packages/plugin-multi-tenant/src/utilities/extractID.ts new file mode 100644 index 000000000..b85c59306 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/extractID.ts @@ -0,0 +1,11 @@ +import type { Tenant } from '../types.js' + +export const extractID = ( + objectOrID: IDType | Tenant, +): IDType => { + if (typeof objectOrID === 'string' || typeof objectOrID === 'number') { + return objectOrID + } + + return objectOrID.id +} diff --git a/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts new file mode 100644 index 000000000..19fd1ea44 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/getGlobalViewRedirect.ts @@ -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 { + 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 +} diff --git a/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts b/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts new file mode 100644 index 000000000..7749fb37e --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/getTenantAccess.ts @@ -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 || [], + }, + } +} diff --git a/packages/plugin-multi-tenant/src/utilities/getTenantFromCookie.ts b/packages/plugin-multi-tenant/src/utilities/getTenantFromCookie.ts new file mode 100644 index 000000000..c01dc68ea --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/getTenantFromCookie.ts @@ -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 +} diff --git a/packages/plugin-multi-tenant/src/utilities/getTenantListFilter.ts b/packages/plugin-multi-tenant/src/utilities/getTenantListFilter.ts new file mode 100644 index 000000000..d1499c953 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/getTenantListFilter.ts @@ -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, + }, + } +} diff --git a/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts b/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts new file mode 100644 index 000000000..fce80f19e --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/getUserTenantIDs.ts @@ -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 = ( + user: null | UserWithTenantsField, +): IDType[] => { + if (!user) { + return [] + } + + return ( + user?.tenants?.reduce((acc, { tenant }) => { + if (tenant) { + acc.push(extractID(tenant as Tenant)) + } + + return acc + }, []) || [] + ) +} diff --git a/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts b/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts new file mode 100644 index 000000000..82b609123 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/withTenantAccess.ts @@ -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 = { + accessFunction?: Access + fieldName: string + userHasAccessToAllTenants: Required< + MultiTenantPluginConfig + >['userHasAccessToAllTenants'] +} +export const withTenantAccess = + ({ accessFunction, fieldName, userHasAccessToAllTenants }: Args) => + async (args: AccessArgs): Promise => { + 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 + } diff --git a/packages/plugin-multi-tenant/src/utilities/withTenantListFilter.ts b/packages/plugin-multi-tenant/src/utilities/withTenantListFilter.ts new file mode 100644 index 000000000..01e9a3be4 --- /dev/null +++ b/packages/plugin-multi-tenant/src/utilities/withTenantListFilter.ts @@ -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 + } diff --git a/packages/plugin-multi-tenant/tsconfig.json b/packages/plugin-multi-tenant/tsconfig.json new file mode 100644 index 000000000..dd036fd84 --- /dev/null +++ b/packages/plugin-multi-tenant/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [{ "path": "../payload" }, { "path": "../ui" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e3a48949..24df4b116 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/generate-template-variations.ts b/scripts/generate-template-variations.ts index b75cd2c30..a2b8f3b33 100644 --- a/scripts/generate-template-variations.ts +++ b/scripts/generate-template-variations.ts @@ -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) diff --git a/scripts/lib/publishList.ts b/scripts/lib/publishList.ts index 9487615e0..eb6fc82fe 100644 --- a/scripts/lib/publishList.ts +++ b/scripts/lib/publishList.ts @@ -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', diff --git a/test/package.json b/test/package.json index 9748841d7..7e3ceb459 100644 --- a/test/package.json +++ b/test/package.json @@ -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:*", diff --git a/test/plugin-multi-tenant/collections/Links.ts b/test/plugin-multi-tenant/collections/Links.ts new file mode 100644 index 000000000..23502bd2f --- /dev/null +++ b/test/plugin-multi-tenant/collections/Links.ts @@ -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', + }, + ], +} diff --git a/test/plugin-multi-tenant/collections/Posts.ts b/test/plugin-multi-tenant/collections/Posts.ts new file mode 100644 index 000000000..b04e18867 --- /dev/null +++ b/test/plugin-multi-tenant/collections/Posts.ts @@ -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', + }, + ], +} diff --git a/test/plugin-multi-tenant/collections/Tenants.ts b/test/plugin-multi-tenant/collections/Tenants.ts new file mode 100644 index 000000000..18730c255 --- /dev/null +++ b/test/plugin-multi-tenant/collections/Tenants.ts @@ -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, + }, + ], +} diff --git a/test/plugin-multi-tenant/collections/Users.ts b/test/plugin-multi-tenant/collections/Users.ts new file mode 100644 index 000000000..ebb6b9540 --- /dev/null +++ b/test/plugin-multi-tenant/collections/Users.ts @@ -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, + }, + ], +} diff --git a/test/plugin-multi-tenant/config.ts b/test/plugin-multi-tenant/config.ts new file mode 100644 index 000000000..ddce7c5d6 --- /dev/null +++ b/test/plugin-multi-tenant/config.ts @@ -0,0 +1,55 @@ +import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant' +import { fileURLToPath } from 'node:url' +import path from 'path' +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +import type { Config as ConfigType } from './payload-types.js' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { LinksCollection } from './collections/Links.js' +import { Posts } from './collections/Posts.js' +import { Tenants } from './collections/Tenants.js' +import { Users } from './collections/Users.js' +import { NavigationGlobalCollection } from './globals/Navigation.js' +import { seed } from './seed/index.js' + +export default buildConfigWithDefaults({ + collections: [Users, Tenants, Posts, LinksCollection, NavigationGlobalCollection], + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + onInit: seed, + plugins: [ + multiTenantPlugin({ + userHasAccessToAllTenants: (user) => Boolean(user.roles?.includes('admin')), + tenantsArrayField: { + rowFields: [ + { + name: 'roles', + type: 'select', + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ], + }, + ], + }, + tenantField: { + access: {}, + }, + collections: { + posts: {}, + links: {}, + 'navigation-global': { + isGlobal: true, + }, + }, + }), + ], + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/plugin-multi-tenant/eslint.config.js b/test/plugin-multi-tenant/eslint.config.js new file mode 100644 index 000000000..d6b5fea55 --- /dev/null +++ b/test/plugin-multi-tenant/eslint.config.js @@ -0,0 +1,19 @@ +import { rootParserOptions } from '../../eslint.config.js' +import testEslintConfig from '../eslint.config.js' + +/** @typedef {import('eslint').Linter.Config} Config */ + +/** @type {Config[]} */ +export const index = [ + ...testEslintConfig, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + ...rootParserOptions, + }, + }, + }, +] + +export default index diff --git a/test/plugin-multi-tenant/globals/Navigation.ts b/test/plugin-multi-tenant/globals/Navigation.ts new file mode 100644 index 000000000..6ddec6770 --- /dev/null +++ b/test/plugin-multi-tenant/globals/Navigation.ts @@ -0,0 +1,14 @@ +import type { CollectionConfig } from 'payload' + +export const NavigationGlobalCollection: CollectionConfig = { + slug: 'navigation-global', + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + }, + ], +} diff --git a/test/plugin-multi-tenant/int.spec.ts b/test/plugin-multi-tenant/int.spec.ts new file mode 100644 index 000000000..100e11720 --- /dev/null +++ b/test/plugin-multi-tenant/int.spec.ts @@ -0,0 +1,54 @@ +import path from 'path' +import { NotFound, type Payload } from 'payload' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' + +import type { NextRESTClient } from '../helpers/NextRESTClient.js' + +import { devUser } from '../credentials.js' +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +let payload: Payload +let restClient: NextRESTClient +let token: string + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('@payloadcms/plugin-multi-tenant', () => { + beforeAll(async () => { + ;({ payload, restClient } = await initPayloadInt(dirname)) + + const data = await restClient + .POST('/users/login', { + body: JSON.stringify({ + email: devUser.email, + password: devUser.password, + }), + }) + .then((res) => res.json()) + + token = data.token + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + describe('tenants', () => { + it('should create a tenant', async () => { + const tenant1 = await payload.create({ + collection: 'tenants', + data: { + name: 'tenant1', + domain: 'tenant1.com', + slug: 'tenant1', + }, + }) + + expect(tenant1).toHaveProperty('id') + }) + }) +}) diff --git a/test/plugin-multi-tenant/payload-types.ts b/test/plugin-multi-tenant/payload-types.ts new file mode 100644 index 000000000..617d534e1 --- /dev/null +++ b/test/plugin-multi-tenant/payload-types.ts @@ -0,0 +1,321 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config { + auth: { + users: UserAuthOperations; + }; + collections: { + users: User; + tenants: Tenant; + posts: Post; + links: Link; + 'navigation-global': NavigationGlobal; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + tenants: TenantsSelect | TenantsSelect; + posts: PostsSelect | PostsSelect; + links: LinksSelect | LinksSelect; + 'navigation-global': NavigationGlobalSelect | NavigationGlobalSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + globals: {}; + globalsSelect: {}; + locale: null; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + roles?: ('admin' | 'user')[] | null; + tenants?: + | { + tenant: string | Tenant; + roles?: ('admin' | 'user') | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tenants". + */ +export interface Tenant { + id: string; + name: string; + slug: string; + domain: string; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + tenant?: (string | null) | Tenant; + title: string; + excerpt?: string | null; + slug?: string | null; + relatedLinks?: (string | null) | Link; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "links". + */ +export interface Link { + id: string; + tenant?: (string | null) | Tenant; + title?: string | null; + url?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "navigation-global". + */ +export interface NavigationGlobal { + id: string; + tenant?: (string | null) | Tenant; + title?: string | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'users'; + value: string | User; + } | null) + | ({ + relationTo: 'tenants'; + value: string | Tenant; + } | null) + | ({ + relationTo: 'posts'; + value: string | Post; + } | null) + | ({ + relationTo: 'links'; + value: string | Link; + } | null) + | ({ + relationTo: 'navigation-global'; + value: string | NavigationGlobal; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + roles?: T; + tenants?: + | T + | { + tenant?: T; + roles?: T; + id?: T; + }; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "tenants_select". + */ +export interface TenantsSelect { + name?: T; + slug?: T; + domain?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts_select". + */ +export interface PostsSelect { + tenant?: T; + title?: T; + excerpt?: T; + slug?: T; + relatedLinks?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "links_select". + */ +export interface LinksSelect { + tenant?: T; + title?: T; + url?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "navigation-global_select". + */ +export interface NavigationGlobalSelect { + tenant?: T; + title?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/plugin-multi-tenant/seed/index.ts b/test/plugin-multi-tenant/seed/index.ts new file mode 100644 index 000000000..0445c1332 --- /dev/null +++ b/test/plugin-multi-tenant/seed/index.ts @@ -0,0 +1,63 @@ +import type { Config } from 'payload' + +import { devUser, regularUser } from '../../credentials.js' + +export const seed: Config['onInit'] = async (payload) => { + // create tenants + const tenant1 = await payload.create({ + collection: 'tenants', + data: { + name: 'Blue Dog', + slug: 'blue-dog', + domain: 'bluedog.com', + }, + }) + const tenant2 = await payload.create({ + collection: 'tenants', + data: { + name: 'Steel Cat', + slug: 'steel-cat', + domain: 'steelcat.com', + }, + }) + + // create posts + await payload.create({ + collection: 'posts', + data: { + title: 'Blue Dog Post', + tenant: tenant1.id, + }, + }) + await payload.create({ + collection: 'posts', + data: { + title: 'Steel Cat Post', + tenant: tenant2.id, + }, + }) + + // create users + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + roles: ['admin'], + }, + }) + + await payload.create({ + collection: 'users', + data: { + email: regularUser.email, + password: regularUser.password, + roles: ['user'], + tenants: [ + { + tenant: tenant1.id, + }, + ], + }, + }) +} diff --git a/test/plugin-multi-tenant/shared.ts b/test/plugin-multi-tenant/shared.ts new file mode 100644 index 000000000..35466718b --- /dev/null +++ b/test/plugin-multi-tenant/shared.ts @@ -0,0 +1,5 @@ +export const tenantsSlug = 'tenants' + +export const postsSlug = 'posts' + +export const usersSlug = 'users' diff --git a/test/plugin-multi-tenant/tsconfig.eslint.json b/test/plugin-multi-tenant/tsconfig.eslint.json new file mode 100644 index 000000000..b34cc7afb --- /dev/null +++ b/test/plugin-multi-tenant/tsconfig.eslint.json @@ -0,0 +1,13 @@ +{ + // extend your base config to share compilerOptions, etc + //"extends": "./tsconfig.json", + "compilerOptions": { + // ensure that nobody can accidentally use this config for a build + "noEmit": true + }, + "include": [ + // whatever paths you intend to lint + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/test/plugin-multi-tenant/tsconfig.json b/test/plugin-multi-tenant/tsconfig.json new file mode 100644 index 000000000..3c43903cf --- /dev/null +++ b/test/plugin-multi-tenant/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/test/setupProd.ts b/test/setupProd.ts index 8dfb48a8d..1f2a524f3 100644 --- a/test/setupProd.ts +++ b/test/setupProd.ts @@ -23,6 +23,7 @@ export const tgzToPkgNameMap = { '@payloadcms/payload-cloud': 'payloadcms-payload-cloud-*', '@payloadcms/plugin-cloud-storage': 'payloadcms-plugin-cloud-storage-*', '@payloadcms/plugin-form-builder': 'payloadcms-plugin-form-builder-*', + '@payloadcms/plugin-multi-tenant': 'payloadcms-plugin-multi-tenant-*', '@payloadcms/plugin-nested-docs': 'payloadcms-plugin-nested-docs-*', '@payloadcms/plugin-redirects': 'payloadcms-plugin-redirects-*', '@payloadcms/plugin-search': 'payloadcms-plugin-search-*', diff --git a/tsconfig.base.json b/tsconfig.base.json index f1309a2ad..e66f005b5 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -32,7 +32,7 @@ } ], "paths": { - "@payload-config": ["./test/field-perf/config.ts"], + "@payload-config": ["./test/_community/config.ts"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], @@ -56,6 +56,17 @@ "@payloadcms/plugin-form-builder/client": [ "./packages/plugin-form-builder/src/exports/client.ts" ], + "@payloadcms/plugin-multi-tenant/rsc": ["./packages/plugin-multi-tenant/src/exports/rsc.ts"], + "@payloadcms/plugin-multi-tenant/utilities": [ + "./packages/plugin-multi-tenant/src/exports/utilities.ts" + ], + "@payloadcms/plugin-multi-tenant/fields": [ + "./packages/plugin-multi-tenant/src/exports/fields.ts" + ], + "@payloadcms/plugin-multi-tenant/client": [ + "./packages/plugin-multi-tenant/src/exports/client.ts" + ], + "@payloadcms/plugin-multi-tenant": ["./packages/plugin-multi-tenant/src/index.ts"], "@payloadcms/next": ["./packages/next/src/exports/*"] } }, diff --git a/tsconfig.json b/tsconfig.json index 4e940938e..a9f8afbb3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -61,6 +61,9 @@ { "path": "./packages/plugin-stripe" }, + { + "path": "./packages/plugin-multi-tenant" + }, { "path": "./packages/richtext-slate" }, @@ -72,10 +75,11 @@ }, { "path": "./packages/ui" - } + }, ], "include": [ "${configDir}/src", - ".next/types/**/*.ts" + ".next/types/**/*.ts", + "./scripts/**/*.ts", ] }