chore(examples): adds optional tenant-based cookie handling by domain in multi-tenant example (#8490)
- Adds optional tenant-based cookie handling based by domain (commented out to leave functionality out by default) - Removes 2.0 multi-tenant example - Updates `examples/multi-tenant-single-domain` --> `examples/multi-tenant`
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-example-multi-tenant-single-domain
|
||||
PAYLOAD_SECRET=PAYLOAD_MULTI_TENANT_EXAMPLE_SECRET_KEY
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
@@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@payloadcms'],
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
@@ -1,79 +0,0 @@
|
||||
# Payload Multi-Tenant Example (Single Domain)
|
||||
|
||||
This example demonstrates how to achieve a multi-tenancy in [Payload](https://github.com/payloadcms/payload) on a single domain. Tenants are separated by a `Tenants` collection.
|
||||
|
||||
## Quick Start
|
||||
|
||||
To spin up this example locally, follow these steps:
|
||||
|
||||
1. Clone this repo
|
||||
1. `cd` into this directory and run `pnpm i --ignore-workspace`\*, `yarn`, or `npm install`
|
||||
|
||||
> \*If you are running using pnpm within the Payload Monorepo, the `--ignore-workspace` flag is needed so that pnpm generates a lockfile in this example's directory despite the fact that one exists in root.
|
||||
|
||||
1. `pnpm dev`, `yarn dev` or `npm run dev` to start the server
|
||||
- Press `y` when prompted to seed the database
|
||||
1. `open http://localhost:3000` to access the home page
|
||||
1. `open http://localhost:3000/admin` to access the admin panel
|
||||
- Login with email `demo@payloadcms.com` and password `demo`
|
||||
|
||||
## How it works
|
||||
|
||||
A multi-tenant Payload application is a single server that hosts multiple "tenants". Examples of tenants may be your agency's clients, your business conglomerate's organizations, or your SaaS customers.
|
||||
|
||||
Each tenant has its own set of users, pages, and other data that is scoped to that tenant. This means that your application will be shared across tenants but the data will be scoped to each tenant.
|
||||
|
||||
### Collections
|
||||
|
||||
See the [Collections](https://payloadcms.com/docs/configuration/collections) docs for details on how to extend any of this functionality.
|
||||
|
||||
- #### Users
|
||||
|
||||
The `users` collection is auth-enabled and encompass both app-wide and tenant-scoped users based on the value of their `roles` and `tenants` fields. Users with the role `super-admin` can manage your entire application, while users with the _tenant role_ of `admin` have limited access to the platform and can manage only the tenant(s) they are assigned to, see [Tenants](#tenants) for more details.
|
||||
|
||||
For additional help with authentication, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/auth/cms#readme) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
|
||||
|
||||
- #### Tenants
|
||||
|
||||
A `tenants` collection is used to achieve tenant-based access control. Each user is assigned an array of `tenants` which includes a relationship to a `tenant` and their `roles` within that tenant. You can then scope any document within your application to any of your tenants using a simple [relationship](https://payloadcms.com/docs/fields/relationship) field on the `users` or `pages` collections, or any other collection that your application needs. The value of this field is used to filter documents in the admin panel and API to ensure that users can only access documents that belong to their tenant and are within their role. See [Access Control](#access-control) for more details.
|
||||
|
||||
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview) docs.
|
||||
|
||||
- #### Pages
|
||||
|
||||
Each page is assigned a `tenant` which is used to control access and scope API requests. Pages that are created by tenants are automatically assigned that tenant based on that user's `lastLoggedInTenant` field.
|
||||
|
||||
## Access control
|
||||
|
||||
Basic role-based access control is setup to determine what users can and cannot do based on their roles, which are:
|
||||
|
||||
- `super-admin`: They can access the Payload admin panel to manage your multi-tenant application. They can see all tenants and make all operations.
|
||||
- `user`: They can only access the Payload admin panel if they are a tenant-admin, in which case they have a limited access to operations based on their tenant (see below).
|
||||
|
||||
This applies to each collection in the following ways:
|
||||
|
||||
- `users`: Only super-admins, tenant-admins, and the user themselves can access their profile. Anyone can create a user, but only these admins can delete users. See [Users](#users) for more details.
|
||||
- `tenants`: Only super-admins and tenant-admins can read, create, update, or delete tenants. See [Tenants](#tenants) for more details.
|
||||
- `pages`: Everyone can access pages, but only super-admins and tenant-admins can create, update, or delete them.
|
||||
|
||||
When a user logs in, a `lastLoggedInTenant` field is saved to their profile. This is done by reading the value of `req.headers.host`, querying for a tenant with a matching `domain`, and verifying that the user is a member of that tenant. This field is then used to automatically assign the tenant to any documents that the user creates, such as pages. Super-admins can also use this field to browse the admin panel as a specific tenant.
|
||||
|
||||
> If you have versions and drafts enabled on your pages, you will need to add additional read access control condition to check the user's tenants that prevents them from accessing draft documents of other tenants.
|
||||
|
||||
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
|
||||
|
||||
## CORS
|
||||
|
||||
This multi-tenant setup requires an open CORS policy. Since each tenant contains a dynamic list of domains, there's no way to know specifically which domains to whitelist at runtime without significant performance implications. This also means that the `serverURL` is not set, as this scopes all requests to a single domain.
|
||||
|
||||
Alternatively, if you know the domains of your tenants ahead of time and these values won't change often, you could simply remove the `domains` field altogether and instead use static values.
|
||||
|
||||
For more details on this, see the [CORS](https://payloadcms.com/docs/production/preventing-abuse#cross-origin-resource-sharing-cors) docs.
|
||||
|
||||
## Front-end
|
||||
|
||||
The frontend is scaffolded out in this example directory. You can view the code for rendering pages at `/src/app/(app)/[tenant]/[...slug]/page.tsx`. This is a starter template, you may need to adjust the app to better fit your needs.
|
||||
|
||||
## Questions
|
||||
|
||||
If you have any issues or questions, reach out to us on [Discord](https://discord.com/invite/payload) or start a [GitHub discussion](https://github.com/payloadcms/payload/discussions).
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "multi-tenant-single-domain",
|
||||
"version": "1.0.0",
|
||||
"description": "An example of a multi tenant application, using a single domain",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"_dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation && pnpm seed && next dev --turbo",
|
||||
"generate:schema": "payload-graphql generate:schema",
|
||||
"generate:types": "payload generate:types",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"seed": "npm run payload migrate:fresh",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-mongodb": "3.0.0-beta.58",
|
||||
"@payloadcms/next": "3.0.0-beta.58",
|
||||
"@payloadcms/richtext-lexical": "3.0.0-beta.58",
|
||||
"@payloadcms/ui": "3.0.0-beta.58",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^8.2.0",
|
||||
"graphql": "^16.9.0",
|
||||
"next": "15.0.0-rc.0",
|
||||
"payload": "3.0.0-beta.58",
|
||||
"qs": "^6.12.1",
|
||||
"react": "19.0.0-rc-f994737d14-20240522",
|
||||
"react-dom": "19.0.0-rc-f994737d14-20240522",
|
||||
"sharp": "0.32.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/graphql": "3.0.0-beta.58",
|
||||
"@swc/core": "^1.6.13",
|
||||
"@types/react": "npm:types-react@19.0.0-beta.2",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-beta.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "15.0.0-rc.0",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "5.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@19.0.0-beta.2",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-beta.2"
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@19.0.0-beta.2",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-beta.2"
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { tenantField } from '../../fields/TenantField'
|
||||
import { isPayloadAdminPanel } from '../../utilities/isPayloadAdminPanel'
|
||||
import { canMutatePage, filterByTenantRead } from './access/byTenant'
|
||||
import { externalReadAccess } from './access/externalReadAccess'
|
||||
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
create: canMutatePage,
|
||||
delete: canMutatePage,
|
||||
read: (args) => {
|
||||
// when viewing pages inside the admin panel
|
||||
// restrict access to the ones your user has access to
|
||||
if (isPayloadAdminPanel(args.req)) return filterByTenantRead(args)
|
||||
|
||||
// when viewing pages from outside the admin panel
|
||||
// you should be able to see your tenants and public tenants
|
||||
return externalReadAccess(args)
|
||||
},
|
||||
update: canMutatePage,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
defaultValue: 'home',
|
||||
hooks: {
|
||||
beforeValidate: [ensureUniqueSlug],
|
||||
},
|
||||
index: true,
|
||||
},
|
||||
tenantField,
|
||||
],
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../access/isSuperAdmin'
|
||||
import { canMutateTenant, filterByTenantRead } from './access/byTenant'
|
||||
|
||||
export const Tenants: CollectionConfig = {
|
||||
slug: 'tenants',
|
||||
access: {
|
||||
create: isSuperAdmin,
|
||||
delete: canMutateTenant,
|
||||
read: filterByTenantRead,
|
||||
update: canMutateTenant,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Used for url paths, example: /tenant-slug/page-slug',
|
||||
},
|
||||
index: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'public',
|
||||
type: 'checkbox',
|
||||
admin: {
|
||||
description: 'If checked, logging in is not required.',
|
||||
position: 'sidebar',
|
||||
},
|
||||
defaultValue: false,
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import type { User } from '../../payload-types'
|
||||
|
||||
import { getTenantAdminTenantAccessIDs } from '../../utilities/getTenantAccessIDs'
|
||||
import { createAccess } from './access/create'
|
||||
import { readAccess } from './access/read'
|
||||
import { updateAndDeleteAccess } from './access/updateAndDelete'
|
||||
import { externalUsersLogin } from './endpoints/externalUsersLogin'
|
||||
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
|
||||
|
||||
const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
access: {
|
||||
create: createAccess,
|
||||
delete: updateAndDeleteAccess,
|
||||
read: readAccess,
|
||||
update: updateAndDeleteAccess,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
endpoints: [externalUsersLogin],
|
||||
fields: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
defaultValue: ['tenant-viewer'],
|
||||
hasMany: true,
|
||||
options: ['tenant-admin', 'tenant-viewer'],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
saveToJWT: true,
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'text',
|
||||
hooks: {
|
||||
beforeValidate: [ensureUniqueUsername],
|
||||
},
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default Users
|
||||
@@ -1,142 +0,0 @@
|
||||
/* 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: {
|
||||
pages: Page;
|
||||
users: User;
|
||||
tenants: Tenant;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
};
|
||||
globals: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
}
|
||||
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` "pages".
|
||||
*/
|
||||
export interface Page {
|
||||
id: string;
|
||||
title?: string | null;
|
||||
slug?: string | null;
|
||||
tenant: string | Tenant;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tenants".
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
public?: boolean | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
roles?: ('super-admin' | 'user')[] | null;
|
||||
tenants?:
|
||||
| {
|
||||
tenant: string | Tenant;
|
||||
roles: ('tenant-admin' | 'tenant-viewer')[];
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
username?: string | 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` "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` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { Pages } from './collections/Pages'
|
||||
import { Tenants } from './collections/Tenants'
|
||||
import Users from './collections/Users'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
components: {
|
||||
afterNavLinks: ['@/components/TenantSelector#TenantSelectorRSC'],
|
||||
},
|
||||
user: 'users',
|
||||
},
|
||||
collections: [Pages, Users, Tenants],
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI as string,
|
||||
}),
|
||||
editor: lexicalEditor({}),
|
||||
graphQL: {
|
||||
schemaOutputFile: path.resolve(dirname, 'generated-schema.graphql'),
|
||||
},
|
||||
secret: process.env.PAYLOAD_SECRET as string,
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"src/payload.config.ts"
|
||||
],
|
||||
"@payload-types": [
|
||||
"src/payload-types.ts"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,3 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-example-multi-tenant
|
||||
PAYLOAD_SECRET=PAYLOAD_MULTI_TENANT_EXAMPLE_SECRET_KEY
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
PAYLOAD_SEED=true
|
||||
PAYLOAD_DROP_DATABASE=true
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
legacy-peer-deps=true
|
||||
@@ -1,24 +1,27 @@
|
||||
# Payload Multi-Tenant Example
|
||||
|
||||
This example demonstrates how to achieve a multi-tenancy in [Payload](https://github.com/payloadcms/payload). This is a powerful way to vertically scale your application by sharing infrastructure across tenants.
|
||||
This example demonstrates how to achieve a multi-tenancy in [Payload](https://github.com/payloadcms/payload). Tenants are separated by a `Tenants` collection.
|
||||
|
||||
## Quick Start
|
||||
|
||||
To spin up this example locally, follow these steps:
|
||||
|
||||
1. First clone the repo
|
||||
1. Then `cd YOUR_PROJECT_REPO && cp .env.example .env`
|
||||
1. Next `yarn && yarn dev`
|
||||
1. Now `open http://localhost:3000/admin` to access the admin panel
|
||||
1. Login with email `demo@payloadcms.com` and password `demo`
|
||||
1. Clone this repo
|
||||
1. `cd` into this directory and run `pnpm i --ignore-workspace`\*, `yarn`, or `npm install`
|
||||
|
||||
That's it! Changes made in `./src` will be reflected in your app. See the [Development](#development) section for more details on how to log in as a tenant.
|
||||
> \*If you are running using pnpm within the Payload Monorepo, the `--ignore-workspace` flag is needed so that pnpm generates a lockfile in this example's directory despite the fact that one exists in root.
|
||||
|
||||
1. `pnpm dev`, `yarn dev` or `npm run dev` to start the server
|
||||
- Press `y` when prompted to seed the database
|
||||
1. `open http://localhost:3000` to access the home page
|
||||
1. `open http://localhost:3000/admin` to access the admin panel
|
||||
- Login with email `demo@payloadcms.com` and password `demo`
|
||||
|
||||
## How it works
|
||||
|
||||
A multi-tenant Payload application is a single server that hosts multiple "tenants". Examples of tenants may be your agency's clients, your business conglomerate's organizations, or your SaaS customers.
|
||||
|
||||
Each tenant has its own set of users, pages, and other data that is scoped to that tenant. This means that your application will be shared across tenants but the data will be scoped to each tenant. Tenants also run on separate domains entirely, so users are not aware of their tenancy.
|
||||
Each tenant has its own set of users, pages, and other data that is scoped to that tenant. This means that your application will be shared across tenants but the data will be scoped to each tenant.
|
||||
|
||||
### Collections
|
||||
|
||||
@@ -36,9 +39,15 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
|
||||
|
||||
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview) docs.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
- #### Pages
|
||||
|
||||
Each page is assigned a `tenant` which is used to control access and scope API requests. Pages that are created by tenants are automatically assigned that tenant based on that user's `lastLoggedInTenant` field.
|
||||
Each page is assigned a `tenant`, which is used to control access and scope API requests. Only users with the `super-admin` role can create pages, and pages are assigned to specific tenants. Other users can view only the pages assigned to the tenant they are associated with.
|
||||
|
||||
## Access control
|
||||
|
||||
@@ -53,8 +62,6 @@ This applies to each collection in the following ways:
|
||||
- `tenants`: Only super-admins and tenant-admins can read, create, update, or delete tenants. See [Tenants](#tenants) for more details.
|
||||
- `pages`: Everyone can access pages, but only super-admins and tenant-admins can create, update, or delete them.
|
||||
|
||||
When a user logs in, a `lastLoggedInTenant` field is saved to their profile. This is done by reading the value of `req.headers.host`, querying for a tenant with a matching `domain`, and verifying that the user is a member of that tenant. This field is then used to automatically assign the tenant to any documents that the user creates, such as pages. Super-admins can also use this field to browse the admin panel as a specific tenant.
|
||||
|
||||
> If you have versions and drafts enabled on your pages, you will need to add additional read access control condition to check the user's tenants that prevents them from accessing draft documents of other tenants.
|
||||
|
||||
For more details on how to extend this functionality, see the [Payload Access Control](https://payloadcms.com/docs/access-control/overview#access-control) docs.
|
||||
@@ -69,63 +76,7 @@ For more details on this, see the [CORS](https://payloadcms.com/docs/production/
|
||||
|
||||
## Front-end
|
||||
|
||||
If you're building a website or other front-end for your tenant, you will need specify the `tenant` in your requests. For example, if you wanted to fetch all pages for the tenant `ABC`, you would make a request to `/api/pages?where[tenant][slug][equals]=abc`.
|
||||
|
||||
For a head start on building a website for your tenant(s), check out the official [Website Template](https://github.com/payloadcms/template-website). It includes a page layout builder, preview, SEO, and much more. It is not multi-tenant, though, but you can easily take the concepts from that example and apply them here.
|
||||
|
||||
## Development
|
||||
|
||||
To spin up this example locally, follow the [Quick Start](#quick-start).
|
||||
|
||||
### Seed
|
||||
|
||||
On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates a super-admin user with email `demo@payloadcms.com` and password `demo` along with the following tenants:
|
||||
|
||||
- `ABC`
|
||||
- Domains:
|
||||
- `abc.localhost.com:3000`
|
||||
- Users:
|
||||
- `admin@abc.com` with role `admin` and password `test`
|
||||
- `user@abc.com` with role `user` and password `test`
|
||||
- Pages:
|
||||
- `ABC Home` with content `Hello, ABC!`
|
||||
- `BBC`
|
||||
- Domains:
|
||||
- `bbc.localhost.com:3000`
|
||||
- Users:
|
||||
- `admin@bbc.com` with role `admin` and password `test`
|
||||
- `user@bbc.com` with role `user` and password `test`
|
||||
- Pages:
|
||||
- `BBC Home` with content `Hello, BBC!`
|
||||
|
||||
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
|
||||
|
||||
### Hosts file
|
||||
|
||||
To fully experience the multi-tenancy of this example locally, your app must run on one of the domains listed in any of your tenant's `domains` field. The simplest way to do this to add the following lines to your hosts file.
|
||||
|
||||
```bash
|
||||
# these domains were provided in the seed script
|
||||
# if needed, change them based on your own tenant settings
|
||||
# remember to specify the port number when browsing to these domains
|
||||
127.0.0.1 abc.localhost.com
|
||||
127.0.0.1 bbc.localhost.com
|
||||
```
|
||||
|
||||
> On Mac you can find the hosts file at `/etc/hosts`. On Windows, it's at `C:\Windows\System32\drivers\etc\hosts`.
|
||||
|
||||
Then you can access your app at `http://abc.localhost.com:3000` and `http://bbc.localhost.com:3000`. Access control will be scoped to the correct tenant based on that user's `tenants`, see [Access Control](#access-control) for more details.
|
||||
|
||||
## Production
|
||||
|
||||
To run Payload in production, you need to build and serve the Admin panel. To do so, follow these steps:
|
||||
|
||||
1. First, invoke the `payload build` script by running `yarn build` or `npm run build` in your project root. This creates a `./build` directory with a production-ready admin bundle.
|
||||
1. Then, run `yarn serve` or `npm run serve` to run Node in production and serve Payload from the `./build` directory.
|
||||
|
||||
### Deployment
|
||||
|
||||
The easiest way to deploy your project is to use [Payload Cloud](https://payloadcms.com/new/import), a one-click hosting solution to deploy production-ready instances of your Payload apps directly from your GitHub repo. You can also choose to self-host your app, check out the [Deployment](https://payloadcms.com/docs/production/deployment) docs for more details.
|
||||
The frontend is scaffolded out in this example directory. You can view the code for rendering pages at `/src/app/(app)/[tenant]/[...slug]/page.tsx`. This is a starter template, you may need to adjust the app to better fit your needs.
|
||||
|
||||
## Questions
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"ext": "ts",
|
||||
"exec": "ts-node src/server.ts -- -I",
|
||||
"stdin": false
|
||||
}
|
||||
@@ -1,49 +1,55 @@
|
||||
{
|
||||
"name": "payload-example-multi-tenant",
|
||||
"description": "Payload multi-tenant example.",
|
||||
"name": "multi-tenant",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/server.js",
|
||||
"description": "An example of a multi tenant application with Payload",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
|
||||
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
|
||||
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
||||
"build:server": "tsc",
|
||||
"build": "yarn copyfiles && yarn build:payload && yarn build:server",
|
||||
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png}\" dist/",
|
||||
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types",
|
||||
"generate:graphQLSchema": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:graphQLSchema",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint --fix --ext .ts,.tsx src"
|
||||
"_dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation && pnpm seed && next dev",
|
||||
"generate:schema": "payload-graphql generate:schema",
|
||||
"generate:types": "payload generate:types",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"seed": "npm run payload migrate:fresh",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/bundler-webpack": "latest",
|
||||
"@payloadcms/db-mongodb": "latest",
|
||||
"@payloadcms/richtext-slate": "latest",
|
||||
"@payloadcms/db-mongodb": "3.0.0-beta.112",
|
||||
"@payloadcms/next": "3.0.0-beta.112",
|
||||
"@payloadcms/richtext-lexical": "3.0.0-beta.112",
|
||||
"@payloadcms/ui": "3.0.0-beta.112",
|
||||
"cross-env": "^7.0.3",
|
||||
"dotenv": "^8.2.0",
|
||||
"express": "^4.17.1",
|
||||
"payload": "latest"
|
||||
"graphql": "^16.9.0",
|
||||
"next": "15.0.0-canary.173",
|
||||
"payload": "3.0.0-beta.112",
|
||||
"qs": "^6.12.1",
|
||||
"react": "19.0.0-rc-3edc000d-20240926",
|
||||
"react-dom": "19.0.0-rc-3edc000d-20240926",
|
||||
"sharp": "0.32.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "^0.0.1",
|
||||
"@types/express": "^4.17.9",
|
||||
"@types/node": "18.11.3",
|
||||
"@types/react": "18.0.21",
|
||||
"@typescript-eslint/eslint-plugin": "^5.51.0",
|
||||
"@typescript-eslint/parser": "^5.51.0",
|
||||
"copyfiles": "^2.4.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.19.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-filenames": "^1.3.2",
|
||||
"eslint-plugin-import": "2.25.4",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"nodemon": "^2.0.6",
|
||||
"prettier": "^2.7.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.8.4"
|
||||
"@payloadcms/graphql": "3.0.0-beta.112",
|
||||
"@swc/core": "^1.6.13",
|
||||
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "15.0.0-canary.173",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "5.5.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@19.0.0-rc.1",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1"
|
||||
}
|
||||
}
|
||||
|
||||
7316
examples/multi-tenant/pnpm-lock.yaml
generated
Normal file
7316
examples/multi-tenant/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
export const anyone: Access = () => true
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const isSuperAdmin: Access = ({ req }) => {
|
||||
if (!req?.user) return false
|
||||
if (!req?.user) {
|
||||
return false
|
||||
}
|
||||
return Boolean(req.user.roles?.includes('super-admin'))
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Access } from 'payload/config'
|
||||
import type { FieldHook } from 'payload/types'
|
||||
|
||||
import { checkUserRoles } from '../utilities/checkUserRoles'
|
||||
|
||||
export const superAdmins: Access = ({ req: { user } }) => checkUserRoles(['super-admin'], user)
|
||||
|
||||
export const superAdminFieldAccess: FieldHook = ({ req: { user } }) =>
|
||||
checkUserRoles(['super-admin'], user)
|
||||
@@ -9,7 +9,7 @@ import React from 'react'
|
||||
import { RenderPage } from '../../../components/RenderPage'
|
||||
|
||||
export default async function Page({ params }: { params: { slug?: string[]; tenant: string } }) {
|
||||
const headers = getHeaders()
|
||||
const headers = await getHeaders()
|
||||
const payload = await getPayloadHMR({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers })
|
||||
|
||||
@@ -81,7 +81,9 @@ export default async function Page({ params }: { params: { slug?: string[]; tena
|
||||
const pageData = pageQuery.docs?.[0]
|
||||
|
||||
// The page with the provided slug could not be found
|
||||
if (!pageData) return notFound()
|
||||
if (!pageData) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
// The page was found, render the page with data
|
||||
return <RenderPage data={pageData} />
|
||||
@@ -20,7 +20,7 @@ export const Login = ({ tenantSlug }: Props) => {
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!usernameRef?.current?.value || !passwordRef?.current?.value) return
|
||||
if (!usernameRef?.current?.value || !passwordRef?.current?.value) {return}
|
||||
const actionRes = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/users/external-users/login`,
|
||||
{
|
||||
@@ -63,7 +63,9 @@ export const canMutatePage: Access = (args) => {
|
||||
const req = args.req
|
||||
const superAdmin = isSuperAdmin(args)
|
||||
|
||||
if (!req.user) return false
|
||||
if (!req.user) {
|
||||
return false
|
||||
}
|
||||
|
||||
// super admins can mutate pages for any tenant
|
||||
if (superAdmin) {
|
||||
@@ -77,7 +79,9 @@ export const canMutatePage: Access = (args) => {
|
||||
// pages they have access to
|
||||
return (
|
||||
req.user?.tenants?.reduce((hasAccess: boolean, accessRow) => {
|
||||
if (hasAccess) return true
|
||||
if (hasAccess) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
accessRow &&
|
||||
accessRow.tenant === selectedTenant &&
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { Access } from 'payload/types'
|
||||
|
||||
export const lastLoggedInTenant: Access = ({ req: { user }, data }) =>
|
||||
user?.lastLoggedInTenant?.id === data?.id
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
export const loggedIn: Access = ({ req: { user } }) => {
|
||||
return Boolean(user)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
import { checkUserRoles } from '../../../utilities/checkUserRoles'
|
||||
|
||||
// the user must be an admin of the document's tenant
|
||||
export const tenantAdmins: Access = ({ req: { user } }) => {
|
||||
if (checkUserRoles(['super-admin'], user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
tenant: {
|
||||
in:
|
||||
user?.tenants
|
||||
?.map(({ tenant, roles }) =>
|
||||
roles.includes('admin') ? (typeof tenant === 'string' ? tenant : tenant.id) : null,
|
||||
) // eslint-disable-line function-paren-newline
|
||||
.filter(Boolean) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Access } from 'payload/types'
|
||||
|
||||
import { isSuperAdmin } from '../../../utilities/isSuperAdmin'
|
||||
|
||||
export const tenants: Access = ({ req: { user }, data }) =>
|
||||
// individual documents
|
||||
(data?.tenant?.id && user?.lastLoggedInTenant?.id === data.tenant.id) ||
|
||||
(!user?.lastLoggedInTenant?.id && isSuperAdmin(user)) || {
|
||||
// list of documents
|
||||
tenant: {
|
||||
equals: user?.lastLoggedInTenant?.id,
|
||||
},
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
|
||||
|
||||
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
|
||||
// if value is unchanged, skip validation
|
||||
if (originalDoc.slug === value) return value
|
||||
if (originalDoc.slug === value) {return value}
|
||||
|
||||
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
|
||||
const currentTenantID =
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { FieldHook } from 'payload/types'
|
||||
|
||||
const format = (val: string): string =>
|
||||
val
|
||||
.replace(/ /g, '-')
|
||||
.replace(/[^\w-]+/g, '')
|
||||
.toLowerCase()
|
||||
|
||||
const formatSlug =
|
||||
(fallback: string): FieldHook =>
|
||||
({ operation, value, originalDoc, data }) => {
|
||||
if (typeof value === 'string') {
|
||||
return format(value)
|
||||
}
|
||||
|
||||
if (operation === 'create') {
|
||||
const fallbackData = data?.[fallback] || originalDoc?.[fallback]
|
||||
|
||||
if (fallbackData && typeof fallbackData === 'string') {
|
||||
return format(fallbackData)
|
||||
}
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export default formatSlug
|
||||
@@ -1,43 +1,44 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import richText from '../../fields/richText'
|
||||
import { tenant } from '../../fields/tenant'
|
||||
import { loggedIn } from './access/loggedIn'
|
||||
import { tenantAdmins } from './access/tenantAdmins'
|
||||
import { tenants } from './access/tenants'
|
||||
import formatSlug from './hooks/formatSlug'
|
||||
import { tenantField } from '../../fields/TenantField'
|
||||
import { isPayloadAdminPanel } from '../../utilities/isPayloadAdminPanel'
|
||||
import { canMutatePage, filterByTenantRead } from './access/byTenant'
|
||||
import { externalReadAccess } from './access/externalReadAccess'
|
||||
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
create: canMutatePage,
|
||||
delete: canMutatePage,
|
||||
read: (args) => {
|
||||
// when viewing pages inside the admin panel
|
||||
// restrict access to the ones your user has access to
|
||||
if (isPayloadAdminPanel(args.req)) {return filterByTenantRead(args)}
|
||||
|
||||
// when viewing pages from outside the admin panel
|
||||
// you should be able to see your tenants and public tenants
|
||||
return externalReadAccess(args)
|
||||
},
|
||||
update: canMutatePage,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'slug', 'updatedAt'],
|
||||
},
|
||||
access: {
|
||||
read: tenants,
|
||||
create: loggedIn,
|
||||
update: tenantAdmins,
|
||||
delete: tenantAdmins,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
label: 'Slug',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
defaultValue: 'home',
|
||||
hooks: {
|
||||
beforeValidate: [formatSlug('title')],
|
||||
beforeValidate: [ensureUniqueSlug],
|
||||
},
|
||||
index: true,
|
||||
},
|
||||
tenant,
|
||||
richText(),
|
||||
tenantField,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
import { parseCookies } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
|
||||
|
||||
@@ -9,7 +7,9 @@ export const filterByTenantRead: Access = (args) => {
|
||||
const req = args.req
|
||||
|
||||
// Super admin can read all
|
||||
if (isSuperAdmin(args)) return true
|
||||
if (isSuperAdmin(args)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tenantIDs = getTenantAccessIDs(req.user)
|
||||
|
||||
@@ -42,15 +42,15 @@ export const canMutateTenant: Access = (args) => {
|
||||
const req = args.req
|
||||
const superAdmin = isSuperAdmin(args)
|
||||
|
||||
if (!req.user) return false
|
||||
if (!req.user) {
|
||||
return false
|
||||
}
|
||||
|
||||
// super admins can mutate pages for any tenant
|
||||
if (superAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
const cookies = parseCookies(req.headers)
|
||||
|
||||
return {
|
||||
id: {
|
||||
in:
|
||||
@@ -7,7 +7,7 @@ export const tenantRead: Access = (args) => {
|
||||
const req = args.req
|
||||
|
||||
// Super admin can read all
|
||||
if (isSuperAdmin(args)) return true
|
||||
if (isSuperAdmin(args)) {return true}
|
||||
|
||||
const tenantIDs = getTenantAccessIDs(req.user)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
import { isSuperAdmin } from '../../../utilities/isSuperAdmin'
|
||||
|
||||
// the user must be an admin of the tenant being accessed
|
||||
export const tenantAdmins: Access = ({ req: { user } }) => {
|
||||
if (isSuperAdmin(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
id: {
|
||||
in:
|
||||
user?.tenants
|
||||
?.map(({ tenant, roles }) =>
|
||||
roles.includes('admin') ? (typeof tenant === 'string' ? tenant : tenant.id) : null,
|
||||
) // eslint-disable-line function-paren-newline
|
||||
.filter(Boolean) || [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { superAdmins } from '../../access/superAdmins'
|
||||
import { tenantAdmins } from './access/tenantAdmins'
|
||||
import { isSuperAdmin } from '../../access/isSuperAdmin'
|
||||
import { canMutateTenant, filterByTenantRead } from './access/byTenant'
|
||||
|
||||
export const Tenants: CollectionConfig = {
|
||||
slug: 'tenants',
|
||||
access: {
|
||||
create: superAdmins,
|
||||
read: tenantAdmins,
|
||||
update: tenantAdmins,
|
||||
delete: superAdmins,
|
||||
create: isSuperAdmin,
|
||||
delete: canMutateTenant,
|
||||
read: filterByTenantRead,
|
||||
update: canMutateTenant,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
@@ -20,17 +20,41 @@ 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: 'domains',
|
||||
type: 'array',
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Used for url paths, example: /tenant-slug/page-slug',
|
||||
},
|
||||
index: true,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'public',
|
||||
type: 'checkbox',
|
||||
admin: {
|
||||
description: 'If checked, logging in is not required.',
|
||||
position: 'sidebar',
|
||||
},
|
||||
defaultValue: false,
|
||||
index: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'domain',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Access } from 'payload/config'
|
||||
|
||||
import { isSuperAdmin } from '../../../utilities/isSuperAdmin'
|
||||
import { User } from '../../../payload-types'
|
||||
|
||||
export const adminsAndSelf: Access<any, User> = async ({ req: { user } }) => {
|
||||
if (user) {
|
||||
const isSuper = isSuperAdmin(user)
|
||||
|
||||
// allow super-admins through only if they have not scoped their user via `lastLoggedInTenant`
|
||||
if (isSuper && !user?.lastLoggedInTenant) {
|
||||
return true
|
||||
}
|
||||
|
||||
// allow users to read themselves and any users within the tenants they are admins of
|
||||
return {
|
||||
or: [
|
||||
{
|
||||
id: {
|
||||
equals: user.id,
|
||||
},
|
||||
},
|
||||
...(isSuper
|
||||
? [
|
||||
{
|
||||
'tenants.tenant': {
|
||||
in: [
|
||||
typeof user?.lastLoggedInTenant === 'string'
|
||||
? user?.lastLoggedInTenant
|
||||
: user?.lastLoggedInTenant?.id,
|
||||
].filter(Boolean),
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
'tenants.tenant': {
|
||||
in:
|
||||
user?.tenants
|
||||
?.map(({ tenant, roles }) =>
|
||||
roles.includes('admin')
|
||||
? typeof tenant === 'string'
|
||||
? tenant
|
||||
: tenant.id
|
||||
: null,
|
||||
) // eslint-disable-line function-paren-newline
|
||||
.filter(Boolean) || [],
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,13 @@ import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAcces
|
||||
|
||||
export const createAccess: Access<User> = (args) => {
|
||||
const { req } = args
|
||||
if (!req.user) return false
|
||||
if (!req.user) {return false}
|
||||
|
||||
if (isSuperAdmin(args)) return true
|
||||
if (isSuperAdmin(args)) {return true}
|
||||
|
||||
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
|
||||
|
||||
if (adminTenantAccessIDs.length > 0) return true
|
||||
if (adminTenantAccessIDs.length > 0) {return true}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const isAccessingSelf: Access = ({ id, req }) => {
|
||||
if (!req?.user) return false
|
||||
if (!req?.user) {return false}
|
||||
return req.user.id === id
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAcces
|
||||
|
||||
export const readAccess: Access<User> = (args) => {
|
||||
const { req } = args
|
||||
if (!req?.user) return false
|
||||
if (!req?.user) {
|
||||
return false
|
||||
}
|
||||
|
||||
const cookies = parseCookies(req.headers)
|
||||
const superAdmin = isSuperAdmin(args)
|
||||
@@ -39,7 +41,9 @@ export const readAccess: Access<User> = (args) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (superAdmin) return true
|
||||
if (superAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { FieldAccess } from 'payload/types'
|
||||
|
||||
import { checkUserRoles } from '../../../utilities/checkUserRoles'
|
||||
import { checkTenantRoles } from '../utilities/checkTenantRoles'
|
||||
|
||||
export const tenantAdmins: FieldAccess = (args) => {
|
||||
const {
|
||||
req: { user },
|
||||
doc,
|
||||
} = args
|
||||
|
||||
return (
|
||||
checkUserRoles(['super-admin'], user) ||
|
||||
doc?.tenants?.some(({ tenant }) => {
|
||||
const id = typeof tenant === 'string' ? tenant : tenant?.id
|
||||
return checkTenantRoles(['admin'], user, id)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import { getTenantAdminTenantAccessIDs } from '../../../utilities/getTenantAcces
|
||||
|
||||
export const updateAndDeleteAccess: Access = (args) => {
|
||||
const { req } = args
|
||||
if (!req.user) return false
|
||||
if (!req.user) {return false}
|
||||
|
||||
if (isSuperAdmin(args)) return true
|
||||
if (isSuperAdmin(args)) {return true}
|
||||
|
||||
const adminTenantAccessIDs = getTenantAdminTenantAccessIDs(req.user)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
|
||||
|
||||
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
|
||||
// if value is unchanged, skip validation
|
||||
if (originalDoc.username === value) return value
|
||||
if (originalDoc.username === value) {return value}
|
||||
|
||||
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
|
||||
const currentTenantID =
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { AfterChangeHook } from 'payload/dist/collections/config/types'
|
||||
|
||||
export const loginAfterCreate: AfterChangeHook = async ({
|
||||
doc,
|
||||
req,
|
||||
req: { payload, body = {}, res },
|
||||
operation,
|
||||
}) => {
|
||||
if (operation === 'create' && !req.user) {
|
||||
const { email, password } = body
|
||||
|
||||
if (email && password) {
|
||||
const { user, token } = await payload.login({
|
||||
collection: 'users',
|
||||
data: { email, password },
|
||||
req,
|
||||
res,
|
||||
})
|
||||
|
||||
return {
|
||||
...doc,
|
||||
token,
|
||||
user,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return doc
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { AfterLoginHook } from 'payload/dist/collections/config/types'
|
||||
|
||||
export const recordLastLoggedInTenant: AfterLoginHook = async ({ req, user }) => {
|
||||
try {
|
||||
const relatedOrg = await req.payload
|
||||
.find({
|
||||
collection: 'tenants',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
where: {
|
||||
'domains.domain': {
|
||||
in: [req.headers.host],
|
||||
},
|
||||
},
|
||||
})
|
||||
?.then((res) => res.docs?.[0])
|
||||
|
||||
await req.payload.update({
|
||||
id: user.id,
|
||||
collection: 'users',
|
||||
data: {
|
||||
lastLoggedInTenant: relatedOrg?.id || null,
|
||||
},
|
||||
req,
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
req.payload.logger.error({
|
||||
err,
|
||||
msg: `Error recording last logged in tenant for user ${user.id}`,
|
||||
})
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { CollectionAfterLoginHook } from 'payload'
|
||||
|
||||
import { mergeHeaders } from '@payloadcms/next/utilities'
|
||||
import { generateCookie, getCookieExpiration } from 'payload'
|
||||
|
||||
export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, user }) => {
|
||||
const relatedOrg = await req.payload.find({
|
||||
collection: 'tenants',
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
where: {
|
||||
'domains.domain': {
|
||||
in: [req.headers.get('host')],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// If a matching tenant is found, set the 'payload-tenant' cookie
|
||||
if (relatedOrg && relatedOrg.docs.length > 0) {
|
||||
const tenantCookie = generateCookie({
|
||||
name: 'payload-tenant',
|
||||
expires: getCookieExpiration({ seconds: 7200 }),
|
||||
path: '/',
|
||||
returnCookieAsObject: false,
|
||||
value: relatedOrg.docs[0].id,
|
||||
})
|
||||
|
||||
// Merge existing responseHeaders with the new Set-Cookie header
|
||||
const newHeaders = new Headers({
|
||||
'Set-Cookie': tenantCookie as string,
|
||||
})
|
||||
|
||||
// Ensure you merge existing response headers if they already exist
|
||||
req.responseHeaders = req.responseHeaders
|
||||
? mergeHeaders(req.responseHeaders, newHeaders)
|
||||
: newHeaders
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -1,107 +1,73 @@
|
||||
import type { CollectionConfig } from 'payload/types'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { anyone } from '../../access/anyone'
|
||||
import { superAdminFieldAccess } from '../../access/superAdmins'
|
||||
import { adminsAndSelf } from './access/adminsAndSelf'
|
||||
import { tenantAdmins } from './access/tenantAdmins'
|
||||
import { loginAfterCreate } from './hooks/loginAfterCreate'
|
||||
import { recordLastLoggedInTenant } from './hooks/recordLastLoggedInTenant'
|
||||
import { isSuperOrTenantAdmin } from './utilities/isSuperOrTenantAdmin'
|
||||
import { createAccess } from './access/create'
|
||||
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'
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
access: {
|
||||
create: createAccess,
|
||||
delete: updateAndDeleteAccess,
|
||||
read: readAccess,
|
||||
update: updateAndDeleteAccess,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
access: {
|
||||
read: adminsAndSelf,
|
||||
create: anyone,
|
||||
update: adminsAndSelf,
|
||||
delete: adminsAndSelf,
|
||||
admin: isSuperOrTenantAdmin,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [loginAfterCreate],
|
||||
afterLogin: [recordLastLoggedInTenant],
|
||||
},
|
||||
auth: true,
|
||||
endpoints: [externalUsersLogin],
|
||||
fields: [
|
||||
{
|
||||
name: 'firstName',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'lastName',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
defaultValue: ['user'],
|
||||
hasMany: true,
|
||||
required: true,
|
||||
access: {
|
||||
create: superAdminFieldAccess,
|
||||
update: superAdminFieldAccess,
|
||||
read: superAdminFieldAccess,
|
||||
},
|
||||
options: [
|
||||
{
|
||||
label: 'Super Admin',
|
||||
value: 'super-admin',
|
||||
},
|
||||
{
|
||||
label: 'User',
|
||||
value: 'user',
|
||||
},
|
||||
],
|
||||
options: ['super-admin', 'user'],
|
||||
},
|
||||
{
|
||||
name: 'tenants',
|
||||
type: 'array',
|
||||
label: 'Tenants',
|
||||
access: {
|
||||
create: tenantAdmins,
|
||||
update: tenantAdmins,
|
||||
read: tenantAdmins,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'tenant',
|
||||
type: 'relationship',
|
||||
index: true,
|
||||
relationTo: 'tenants',
|
||||
required: true,
|
||||
saveToJWT: true,
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
defaultValue: ['tenant-viewer'],
|
||||
hasMany: true,
|
||||
options: ['tenant-admin', 'tenant-viewer'],
|
||||
required: true,
|
||||
options: [
|
||||
{
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
},
|
||||
{
|
||||
label: 'User',
|
||||
value: 'user',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
saveToJWT: true,
|
||||
},
|
||||
{
|
||||
name: 'lastLoggedInTenant',
|
||||
type: 'relationship',
|
||||
relationTo: 'tenants',
|
||||
name: 'username',
|
||||
type: 'text',
|
||||
hooks: {
|
||||
beforeValidate: [ensureUniqueUsername],
|
||||
},
|
||||
index: true,
|
||||
access: {
|
||||
create: () => false,
|
||||
read: tenantAdmins,
|
||||
update: superAdminFieldAccess,
|
||||
},
|
||||
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],
|
||||
// },
|
||||
}
|
||||
|
||||
export default Users
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { User } from '../../../payload-types'
|
||||
|
||||
export const checkTenantRoles = (
|
||||
allRoles: User['tenants'][0]['roles'] = [],
|
||||
user: User = undefined,
|
||||
tenant: User['tenants'][0]['tenant'] = undefined,
|
||||
): boolean => {
|
||||
if (tenant) {
|
||||
const id = typeof tenant === 'string' ? tenant : tenant?.id
|
||||
|
||||
if (
|
||||
allRoles.some((role) => {
|
||||
return user?.tenants?.some(({ tenant: userTenant, roles }) => {
|
||||
const tenantID = typeof userTenant === 'string' ? userTenant : userTenant?.id
|
||||
return tenantID === id && roles?.includes(role)
|
||||
})
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { PayloadRequest } from 'payload/dist/types'
|
||||
|
||||
import { isSuperAdmin } from '../../../utilities/isSuperAdmin'
|
||||
|
||||
const logs = false
|
||||
|
||||
export const isSuperOrTenantAdmin = async (args: { req: PayloadRequest }): Promise<boolean> => {
|
||||
const {
|
||||
req,
|
||||
req: { user, payload },
|
||||
} = args
|
||||
|
||||
// always allow super admins through
|
||||
if (isSuperAdmin(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
const msg = `Finding tenant with host: '${req.headers.host}'`
|
||||
payload.logger.info({ msg })
|
||||
}
|
||||
|
||||
// read `req.headers.host`, lookup the tenant by `domain` to ensure it exists, and check if the user is an admin of that tenant
|
||||
const foundTenants = await payload.find({
|
||||
collection: 'tenants',
|
||||
where: {
|
||||
'domains.domain': {
|
||||
in: [req.headers.host],
|
||||
},
|
||||
},
|
||||
depth: 0,
|
||||
limit: 1,
|
||||
req,
|
||||
})
|
||||
|
||||
// if this tenant does not exist, deny access
|
||||
if (foundTenants.totalDocs === 0) {
|
||||
if (logs) {
|
||||
const msg = `No tenant found for ${req.headers.host}`
|
||||
payload.logger.info({ msg })
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
const msg = `Found tenant: '${foundTenants.docs?.[0]?.name}', checking if user is an tenant admin`
|
||||
payload.logger.info({ msg })
|
||||
}
|
||||
|
||||
// finally check if the user is an admin of this tenant
|
||||
const tenantWithUser = user?.tenants?.find(
|
||||
({ tenant: userTenant }) => userTenant?.id === foundTenants.docs[0].id,
|
||||
)
|
||||
|
||||
if (tenantWithUser?.roles?.some((role) => role === 'admin')) {
|
||||
if (logs) {
|
||||
const msg = `User is an admin of ${foundTenants.docs[0].name}, allowing access`
|
||||
payload.logger.info({ msg })
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (logs) {
|
||||
const msg = `User is not an admin of ${foundTenants.docs[0].name}, denying access`
|
||||
payload.logger.info({ msg })
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -19,7 +19,9 @@ export const TenantSelector = ({ initialCookie }: { initialCookie?: string }) =>
|
||||
const tenantIDs =
|
||||
user?.tenants?.map(({ tenant }) => {
|
||||
if (tenant) {
|
||||
if (typeof tenant === 'string') return tenant
|
||||
if (typeof tenant === 'string') {
|
||||
return tenant
|
||||
}
|
||||
return tenant.id
|
||||
}
|
||||
}) || []
|
||||
@@ -3,7 +3,7 @@ import React from 'react'
|
||||
|
||||
import { TenantSelector } from './index.client'
|
||||
|
||||
export const TenantSelectorRSC = () => {
|
||||
const cookies = getCookies()
|
||||
export const TenantSelectorRSC = async () => {
|
||||
const cookies = await getCookies()
|
||||
return <TenantSelector initialCookie={cookies.get('payload-tenant')?.value} />
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
config: () => null,
|
||||
}
|
||||
@@ -10,8 +10,8 @@ export const TenantFieldComponent: React.FC<{
|
||||
payload: Payload
|
||||
readOnly: boolean
|
||||
}> = async (args) => {
|
||||
const cookies = getCookies()
|
||||
const headers = getHeaders()
|
||||
const cookies = await getCookies()
|
||||
const headers = await getHeaders()
|
||||
const { user } = await args.payload.auth({ headers })
|
||||
|
||||
if (
|
||||
@@ -8,7 +8,7 @@ export const autofillTenant: FieldHook = ({ req, value }) => {
|
||||
// return that tenant ID as the value
|
||||
if (!value) {
|
||||
const tenantIDs = getTenantAccessIDs(req.user)
|
||||
if (tenantIDs.length === 1) return tenantIDs[0]
|
||||
if (tenantIDs.length === 1) {return tenantIDs[0]}
|
||||
}
|
||||
|
||||
return value
|
||||
@@ -10,7 +10,7 @@ export const tenantField: Field = {
|
||||
access: {
|
||||
read: () => true,
|
||||
update: (args) => {
|
||||
if (isSuperAdmin(args)) return true
|
||||
if (isSuperAdmin(args)) {return true}
|
||||
return tenantFieldUpdate(args)
|
||||
},
|
||||
},
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { Field } from 'payload/types'
|
||||
|
||||
import deepMerge from '../utilities/deepMerge'
|
||||
|
||||
export const appearanceOptions = {
|
||||
primary: {
|
||||
label: 'Primary Button',
|
||||
value: 'primary',
|
||||
},
|
||||
secondary: {
|
||||
label: 'Secondary Button',
|
||||
value: 'secondary',
|
||||
},
|
||||
default: {
|
||||
label: 'Default',
|
||||
value: 'default',
|
||||
},
|
||||
}
|
||||
|
||||
export type LinkAppearances = 'primary' | 'secondary' | 'default'
|
||||
|
||||
type LinkType = (options?: {
|
||||
appearances?: LinkAppearances[] | false
|
||||
disableLabel?: boolean
|
||||
overrides?: Record<string, unknown>
|
||||
}) => Field
|
||||
|
||||
const link: LinkType = ({ appearances, disableLabel = false, overrides = {} } = {}) => {
|
||||
const linkResult: Field = {
|
||||
name: 'link',
|
||||
type: 'group',
|
||||
admin: {
|
||||
hideGutter: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'row',
|
||||
fields: [
|
||||
{
|
||||
name: 'type',
|
||||
type: 'radio',
|
||||
options: [
|
||||
{
|
||||
label: 'Internal link',
|
||||
value: 'reference',
|
||||
},
|
||||
{
|
||||
label: 'Custom URL',
|
||||
value: 'custom',
|
||||
},
|
||||
],
|
||||
defaultValue: 'reference',
|
||||
admin: {
|
||||
layout: 'horizontal',
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'newTab',
|
||||
label: 'Open in new tab',
|
||||
type: 'checkbox',
|
||||
admin: {
|
||||
width: '50%',
|
||||
style: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const linkTypes: Field[] = [
|
||||
{
|
||||
name: 'reference',
|
||||
label: 'Document to link to',
|
||||
type: 'relationship',
|
||||
relationTo: ['pages'],
|
||||
required: true,
|
||||
maxDepth: 1,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'reference',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'Custom URL',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
condition: (_, siblingData) => siblingData?.type === 'custom',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (!disableLabel) {
|
||||
linkTypes[0].admin.width = '50%'
|
||||
linkTypes[1].admin.width = '50%'
|
||||
|
||||
linkResult.fields.push({
|
||||
type: 'row',
|
||||
fields: [
|
||||
...linkTypes,
|
||||
{
|
||||
name: 'label',
|
||||
label: 'Label',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
width: '50%',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
linkResult.fields = [...linkResult.fields, ...linkTypes]
|
||||
}
|
||||
|
||||
if (appearances !== false) {
|
||||
let appearanceOptionsToUse = [
|
||||
appearanceOptions.default,
|
||||
appearanceOptions.primary,
|
||||
appearanceOptions.secondary,
|
||||
]
|
||||
|
||||
if (appearances) {
|
||||
appearanceOptionsToUse = appearances.map((appearance) => appearanceOptions[appearance])
|
||||
}
|
||||
|
||||
linkResult.fields.push({
|
||||
name: 'appearance',
|
||||
type: 'select',
|
||||
defaultValue: 'default',
|
||||
options: appearanceOptionsToUse,
|
||||
admin: {
|
||||
description: 'Choose how the link should be rendered.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return deepMerge(linkResult, overrides)
|
||||
}
|
||||
|
||||
export default link
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { RichTextElement } from '@payloadcms/richtext-slate'
|
||||
|
||||
const elements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'link']
|
||||
|
||||
export default elements
|
||||
@@ -1,92 +0,0 @@
|
||||
import { slateEditor } from '@payloadcms/richtext-slate'
|
||||
import type { RichTextElement, RichTextLeaf } from '@payloadcms/richtext-slate/dist/types'
|
||||
import type { RichTextField } from 'payload/types'
|
||||
|
||||
import deepMerge from '../../utilities/deepMerge'
|
||||
import link from '../link'
|
||||
import elements from './elements'
|
||||
import leaves from './leaves'
|
||||
|
||||
type RichText = (
|
||||
overrides?: Partial<RichTextField>,
|
||||
additions?: {
|
||||
elements?: RichTextElement[]
|
||||
leaves?: RichTextLeaf[]
|
||||
},
|
||||
) => RichTextField
|
||||
|
||||
const richText: RichText = (
|
||||
overrides,
|
||||
additions = {
|
||||
elements: [],
|
||||
leaves: [],
|
||||
},
|
||||
) =>
|
||||
deepMerge<RichTextField, Partial<RichTextField>>(
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
required: true,
|
||||
editor: slateEditor({
|
||||
admin: {
|
||||
upload: {
|
||||
collections: {
|
||||
media: {
|
||||
fields: [
|
||||
{
|
||||
type: 'richText',
|
||||
name: 'caption',
|
||||
label: 'Caption',
|
||||
editor: slateEditor({
|
||||
admin: {
|
||||
elements: [...elements],
|
||||
leaves: [...leaves],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
name: 'alignment',
|
||||
label: 'Alignment',
|
||||
options: [
|
||||
{
|
||||
label: 'Left',
|
||||
value: 'left',
|
||||
},
|
||||
{
|
||||
label: 'Center',
|
||||
value: 'center',
|
||||
},
|
||||
{
|
||||
label: 'Right',
|
||||
value: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'enableLink',
|
||||
type: 'checkbox',
|
||||
label: 'Enable Link',
|
||||
},
|
||||
link({
|
||||
appearances: false,
|
||||
disableLabel: true,
|
||||
overrides: {
|
||||
admin: {
|
||||
condition: (_, data) => Boolean(data?.enableLink),
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
elements: [...elements, ...(additions.elements || [])],
|
||||
leaves: [...leaves, ...(additions.leaves || [])],
|
||||
},
|
||||
}),
|
||||
},
|
||||
overrides,
|
||||
)
|
||||
|
||||
export default richText
|
||||
@@ -1,5 +0,0 @@
|
||||
import { RichTextLeaf } from '@payloadcms/richtext-slate'
|
||||
|
||||
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline']
|
||||
|
||||
export default defaultLeaves
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { FieldAccess } from 'payload/types'
|
||||
|
||||
import { checkUserRoles } from '../../../utilities/checkUserRoles'
|
||||
|
||||
export const tenantAdminFieldAccess: FieldAccess = ({ req: { user }, doc }) => {
|
||||
return (
|
||||
checkUserRoles(['super-admin'], user) ||
|
||||
!doc?.tenant ||
|
||||
(doc?.tenant &&
|
||||
user?.tenants?.some(
|
||||
({ tenant: userTenant, roles }) =>
|
||||
(typeof doc?.tenant === 'string' ? doc?.tenant : doc?.tenant.id) === userTenant?.id &&
|
||||
roles?.includes('admin'),
|
||||
))
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Field } from 'payload/types'
|
||||
|
||||
import { superAdminFieldAccess } from '../../access/superAdmins'
|
||||
import { isSuperAdmin } from '../../utilities/isSuperAdmin'
|
||||
import { tenantAdminFieldAccess } from './access/tenantAdmins'
|
||||
|
||||
export const tenant: Field = {
|
||||
name: 'tenant',
|
||||
type: 'relationship',
|
||||
relationTo: 'tenants',
|
||||
// don't require this field because we need to auto-populate it, see below
|
||||
// required: true,
|
||||
// we also don't want to hide this field because super-admins may need to manage it
|
||||
// to achieve this, create a custom component that conditionally renders the field based on the user's role
|
||||
// hidden: true,
|
||||
index: true,
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
access: {
|
||||
create: superAdminFieldAccess,
|
||||
read: tenantAdminFieldAccess,
|
||||
update: superAdminFieldAccess,
|
||||
},
|
||||
hooks: {
|
||||
// automatically set the tenant to the last logged in tenant
|
||||
// for super admins, allow them to set the tenant
|
||||
beforeChange: [
|
||||
async ({ req, req: { user }, data }) => {
|
||||
if ((await isSuperAdmin(req.user)) && data?.tenant) {
|
||||
return data.tenant
|
||||
}
|
||||
|
||||
if (user?.lastLoggedInTenant?.id) {
|
||||
return user.lastLoggedInTenant.id
|
||||
}
|
||||
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -10,11 +10,15 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||||
},
|
||||
})
|
||||
|
||||
// The 'domains' field is used to associate a domain with this tenant.
|
||||
// Uncomment and set the domain if you want to enable domain-based tenant assignment.
|
||||
|
||||
const tenant1 = await payload.create({
|
||||
collection: 'tenants',
|
||||
data: {
|
||||
name: 'Tenant 1',
|
||||
slug: 'tenant-1',
|
||||
// domains: [{ domain: 'abc.localhost.com:3000' }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -23,6 +27,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||||
data: {
|
||||
name: 'Tenant 2',
|
||||
slug: 'tenant-2',
|
||||
// domains: [{ domain: 'bbc.localhost.com:3000' }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -31,6 +36,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||||
data: {
|
||||
name: 'Tenant 3',
|
||||
slug: 'tenant-3',
|
||||
// domains: [{ domain: 'cbc.localhost.com:3000' }],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,6 +50,10 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||||
roles: ['tenant-admin'],
|
||||
tenant: tenant1.id,
|
||||
},
|
||||
// {
|
||||
// roles: ['tenant-admin'],
|
||||
// tenant: tenant2.id,
|
||||
// },
|
||||
],
|
||||
username: 'tenant1',
|
||||
},
|
||||
@@ -7,93 +7,164 @@
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
collections: {
|
||||
users: User
|
||||
tenants: Tenant
|
||||
pages: Page
|
||||
'payload-preferences': PayloadPreference
|
||||
'payload-migrations': PayloadMigration
|
||||
}
|
||||
globals: {}
|
||||
pages: Page;
|
||||
users: User;
|
||||
tenants: Tenant;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
'payload-preferences': PayloadPreference;
|
||||
'payload-migrations': PayloadMigration;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
};
|
||||
globals: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
}
|
||||
export interface User {
|
||||
id: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
roles: ('super-admin' | 'user')[]
|
||||
tenants?: {
|
||||
tenant: string | Tenant
|
||||
roles: ('admin' | 'user')[]
|
||||
id?: string
|
||||
}[]
|
||||
lastLoggedInTenant?: string | Tenant
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
email: string
|
||||
resetPasswordToken?: string
|
||||
resetPasswordExpiration?: string
|
||||
salt?: string
|
||||
hash?: string
|
||||
loginAttempts?: number
|
||||
lockUntil?: string
|
||||
password?: string
|
||||
}
|
||||
export interface Tenant {
|
||||
id: string
|
||||
name: string
|
||||
domains?: {
|
||||
domain: string
|
||||
id?: string
|
||||
}[]
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
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` "pages".
|
||||
*/
|
||||
export interface Page {
|
||||
id: string
|
||||
title: string
|
||||
slug?: string
|
||||
tenant?: string | Tenant
|
||||
richText: {
|
||||
[k: string]: unknown
|
||||
}[]
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
id: string;
|
||||
title?: string | null;
|
||||
slug?: string | null;
|
||||
tenant: string | Tenant;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
export interface PayloadPreference {
|
||||
id: string
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "tenants".
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
public?: boolean | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
roles?: ('super-admin' | 'user')[] | null;
|
||||
tenants?:
|
||||
| {
|
||||
tenant: string | Tenant;
|
||||
roles: ('tenant-admin' | 'tenant-viewer')[];
|
||||
id?: string | null;
|
||||
}[]
|
||||
| null;
|
||||
username?: string | 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` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'pages';
|
||||
value: string | Page;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'tenants';
|
||||
value: string | Tenant;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users'
|
||||
value: string | User
|
||||
}
|
||||
key?: string
|
||||
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
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
| 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
|
||||
batch?: number
|
||||
updatedAt: string
|
||||
createdAt: string
|
||||
id: string;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
declare module 'payload' {
|
||||
export interface GeneratedTypes {
|
||||
collections: {
|
||||
users: User
|
||||
tenants: Tenant
|
||||
pages: Page
|
||||
'payload-preferences': PayloadPreference
|
||||
'payload-migrations': PayloadMigration
|
||||
}
|
||||
}
|
||||
}
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
@@ -1,39 +1,33 @@
|
||||
import { webpackBundler } from '@payloadcms/bundler-webpack'
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { slateEditor } from '@payloadcms/richtext-slate'
|
||||
import dotenv from 'dotenv'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, '../.env'),
|
||||
})
|
||||
|
||||
import { buildConfig } from 'payload/config'
|
||||
import { buildConfig } from 'payload'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
import { Pages } from './collections/Pages'
|
||||
import { Tenants } from './collections/Tenants'
|
||||
import { Users } from './collections/Users'
|
||||
import Users from './collections/Users'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
collections: [Users, Tenants, Pages],
|
||||
admin: {
|
||||
bundler: webpackBundler(),
|
||||
webpack: (config) => ({
|
||||
...config,
|
||||
resolve: {
|
||||
...config.resolve,
|
||||
alias: {
|
||||
...config.resolve.alias,
|
||||
dotenv: path.resolve(__dirname, './dotenv.js'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
components: {
|
||||
afterNavLinks: ['@/components/TenantSelector#TenantSelectorRSC'],
|
||||
},
|
||||
user: 'users',
|
||||
},
|
||||
editor: slateEditor({}),
|
||||
collections: [Pages, Users, Tenants],
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI,
|
||||
url: process.env.DATABASE_URI as string,
|
||||
}),
|
||||
editor: lexicalEditor({}),
|
||||
graphQL: {
|
||||
schemaOutputFile: path.resolve(dirname, 'generated-schema.graphql'),
|
||||
},
|
||||
secret: process.env.PAYLOAD_SECRET as string,
|
||||
typescript: {
|
||||
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
export const seed = async (payload: Payload): Promise<void> => {
|
||||
// create super admin
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'demo@payloadcms.com',
|
||||
password: 'demo',
|
||||
roles: ['super-admin'],
|
||||
},
|
||||
})
|
||||
|
||||
// create tenants, use `*.localhost.com` so that accidentally forgotten changes the hosts file are acceptable
|
||||
const [abc, bbc] = await Promise.all([
|
||||
await payload.create({
|
||||
collection: 'tenants',
|
||||
data: {
|
||||
name: 'ABC',
|
||||
domains: [{ domain: 'abc.localhost.com:3000' }],
|
||||
},
|
||||
}),
|
||||
await payload.create({
|
||||
collection: 'tenants',
|
||||
data: {
|
||||
name: 'BBC',
|
||||
domains: [{ domain: 'bbc.localhost.com:3000' }],
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// create tenant-scoped admins and users
|
||||
await Promise.all([
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'admin@abc.com',
|
||||
password: 'test',
|
||||
roles: ['user'],
|
||||
tenants: [
|
||||
{
|
||||
tenant: abc.id,
|
||||
roles: ['admin'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'user@abc.com',
|
||||
password: 'test',
|
||||
roles: ['user'],
|
||||
tenants: [
|
||||
{
|
||||
tenant: abc.id,
|
||||
roles: ['user'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'admin@bbc.com',
|
||||
password: 'test',
|
||||
roles: ['user'],
|
||||
tenants: [
|
||||
{
|
||||
tenant: bbc.id,
|
||||
roles: ['admin'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'user@bbc.com',
|
||||
password: 'test',
|
||||
roles: ['user'],
|
||||
tenants: [
|
||||
{
|
||||
tenant: bbc.id,
|
||||
roles: ['user'],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
// create tenant-scoped pages
|
||||
await Promise.all([
|
||||
await payload.create({
|
||||
collection: 'pages',
|
||||
data: {
|
||||
tenant: abc.id,
|
||||
title: 'ABC Home',
|
||||
richText: [
|
||||
{
|
||||
text: 'Hello, ABC!',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
await payload.create({
|
||||
collection: 'pages',
|
||||
data: {
|
||||
title: 'BBC Home',
|
||||
tenant: bbc.id,
|
||||
richText: [
|
||||
{
|
||||
text: 'Hello, BBC!',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, '../.env'),
|
||||
})
|
||||
|
||||
import express from 'express'
|
||||
import payload from 'payload'
|
||||
|
||||
import { seed } from './seed'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.get('/', (_, res) => {
|
||||
res.redirect('/admin')
|
||||
})
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
await payload.init({
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
mongoURL: process.env.MONGODB_URI,
|
||||
express: app,
|
||||
onInit: () => {
|
||||
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
|
||||
},
|
||||
})
|
||||
|
||||
if (process.env.PAYLOAD_SEED === 'true') {
|
||||
payload.logger.info('---- SEEDING DATABASE ----')
|
||||
await seed(payload)
|
||||
}
|
||||
|
||||
app.listen(3000)
|
||||
}
|
||||
|
||||
start()
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { User } from '../payload-types'
|
||||
|
||||
export const checkUserRoles = (allRoles: User['roles'] = [], user: User = undefined): boolean => {
|
||||
if (user) {
|
||||
if (
|
||||
allRoles.some((role) => {
|
||||
return user?.roles?.some((individualRole) => {
|
||||
return individualRole === role
|
||||
})
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Simple object check.
|
||||
* @param item
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isObject(item: unknown): boolean {
|
||||
return item && typeof item === 'object' && !Array.isArray(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects.
|
||||
* @param target
|
||||
* @param ...sources
|
||||
*/
|
||||
export default function deepMerge<T, R>(target: T, source: R): T {
|
||||
const output = { ...target }
|
||||
if (isObject(target) && isObject(source)) {
|
||||
Object.keys(source).forEach((key) => {
|
||||
if (isObject(source[key])) {
|
||||
if (!(key in (target as Record<string, unknown>))) {
|
||||
Object.assign(output, { [key]: source[key] })
|
||||
} else {
|
||||
output[key] = deepMerge(target[key], source[key])
|
||||
}
|
||||
} else {
|
||||
Object.assign(output, { [key]: source[key] })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { User } from '../payload-types'
|
||||
|
||||
export const getTenantAccessIDs = (user: User | null): string[] => {
|
||||
if (!user) return []
|
||||
export const getTenantAccessIDs = (user: null | User): string[] => {
|
||||
if (!user) {return []}
|
||||
return (
|
||||
user?.tenants?.reduce((acc: string[], { tenant }) => {
|
||||
if (tenant) {
|
||||
@@ -12,8 +12,8 @@ export const getTenantAccessIDs = (user: User | null): string[] => {
|
||||
)
|
||||
}
|
||||
|
||||
export const getTenantAdminTenantAccessIDs = (user: User | null): string[] => {
|
||||
if (!user) return []
|
||||
export const getTenantAdminTenantAccessIDs = (user: null | User): string[] => {
|
||||
if (!user) {return []}
|
||||
|
||||
return (
|
||||
user?.tenants?.reduce((acc: string[], { roles, tenant }) => {
|
||||
@@ -1,4 +0,0 @@
|
||||
import { User } from '../payload-types'
|
||||
import { checkUserRoles } from './checkUserRoles'
|
||||
|
||||
export const isSuperAdmin = (user: User): boolean => checkUserRoles(['super-admin'], user)
|
||||
@@ -1,33 +1,48 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"baseUrl": ".",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"node_modules/*": ["./node_modules/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@payload-config": [
|
||||
"src/payload.config.ts"
|
||||
],
|
||||
"@payload-types": [
|
||||
"src/payload-types.ts"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"src/mocks/emptyObject.js"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
],
|
||||
"ts-node": {
|
||||
"transpileOnly": true
|
||||
}
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user