chore(examples): multi tenant using a tenant selector (#7111)
This commit is contained in:
3
examples/multi-tenant-single-domain/.env.example
Normal file
3
examples/multi-tenant-single-domain/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
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
|
||||
4
examples/multi-tenant-single-domain/.eslintrc.cjs
Normal file
4
examples/multi-tenant-single-domain/.eslintrc.cjs
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['@payloadcms'],
|
||||
}
|
||||
5
examples/multi-tenant-single-domain/.gitignore
vendored
Normal file
5
examples/multi-tenant-single-domain/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
build
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
.env
|
||||
24
examples/multi-tenant-single-domain/.swcrc
Normal file
24
examples/multi-tenant-single-domain/.swcrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
75
examples/multi-tenant-single-domain/README.md
Normal file
75
examples/multi-tenant-single-domain/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Payload Multi-Tenant Example
|
||||
|
||||
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. First clone the repo
|
||||
2. `cd YOUR_PROJECT_REPO && cp .env.example .env`
|
||||
3. `pnpm i && pnpm dev`
|
||||
4. run `yarn seed` to seed the database
|
||||
5. open `http://localhost:3000/admin` to access the admin panel
|
||||
6. 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).
|
||||
5
examples/multi-tenant-single-domain/next-env.d.ts
vendored
Normal file
5
examples/multi-tenant-single-domain/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
55
examples/multi-tenant-single-domain/package.json
Normal file
55
examples/multi-tenant-single-domain/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "multi-tenant-single-domain",
|
||||
"description": "An example of a multi tenant application, using a single domain",
|
||||
"version": "1.0.0",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^18.20.2 || >=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev --turbo",
|
||||
"_dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
|
||||
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
|
||||
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
|
||||
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
|
||||
"generate:types": "payload generate:types",
|
||||
"generate:schema": "payload-graphql generate:schema",
|
||||
"seed": "tsx ./scripts/seed.ts"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
182
examples/multi-tenant-single-domain/scripts/seed.ts
Normal file
182
examples/multi-tenant-single-domain/scripts/seed.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* This is an example of a standalone script that loads in the Payload config
|
||||
* and uses the Payload Local API to query the database.
|
||||
*/
|
||||
|
||||
import type { Payload } from 'payload';
|
||||
|
||||
import { getPayload } from 'payload'
|
||||
import { importConfig } from 'payload/node'
|
||||
|
||||
async function findOrCreateTenant({ data, payload }: { data: any; payload: Payload }) {
|
||||
const tenantsQuery = await payload.find({
|
||||
collection: 'tenants',
|
||||
where: {
|
||||
slug: {
|
||||
equals: data.slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (tenantsQuery.docs?.[0]) return tenantsQuery.docs[0]
|
||||
|
||||
return payload.create({
|
||||
collection: 'tenants',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
async function findOrCreateUser({ data, payload }: { data: any; payload: Payload }) {
|
||||
const usersQuery = await payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: {
|
||||
equals: data.email,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (usersQuery.docs?.[0]) return usersQuery.docs[0]
|
||||
|
||||
return payload.create({
|
||||
collection: 'users',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
async function findOrCreatePage({ data, payload }: { data: any; payload: Payload }) {
|
||||
const pagesQuery = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: {
|
||||
equals: data.slug,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (pagesQuery.docs?.[0]) return pagesQuery.docs[0]
|
||||
|
||||
return payload.create({
|
||||
collection: 'pages',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const awaitedConfig = await importConfig('../src/payload.config.ts')
|
||||
const payload = await getPayload({ config: awaitedConfig })
|
||||
|
||||
const tenant1 = await findOrCreateTenant({
|
||||
data: {
|
||||
name: 'Tenant 1',
|
||||
slug: 'tenant-1',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
const tenant2 = await findOrCreateTenant({
|
||||
data: {
|
||||
name: 'Tenant 2',
|
||||
slug: 'tenant-2',
|
||||
public: true,
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
const tenant3 = await findOrCreateTenant({
|
||||
data: {
|
||||
name: 'Tenant 3',
|
||||
slug: 'tenant-3',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
await findOrCreateUser({
|
||||
data: {
|
||||
email: 'demo@payloadcms.com',
|
||||
password: 'demo',
|
||||
roles: ['super-admin'],
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
await findOrCreateUser({
|
||||
data: {
|
||||
email: 'tenant1@payloadcms.com',
|
||||
password: 'test',
|
||||
tenants: [
|
||||
{
|
||||
roles: ['super-admin'],
|
||||
tenant: tenant1.id,
|
||||
},
|
||||
],
|
||||
username: 'tenant1',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
await findOrCreateUser({
|
||||
data: {
|
||||
email: 'tenant2@payloadcms.com',
|
||||
password: 'test',
|
||||
tenants: [
|
||||
{
|
||||
roles: ['super-admin'],
|
||||
tenant: tenant2.id,
|
||||
},
|
||||
],
|
||||
username: 'tenant2',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
await findOrCreateUser({
|
||||
data: {
|
||||
email: 'multi-admin@payloadcms.com',
|
||||
password: 'test',
|
||||
tenants: [
|
||||
{
|
||||
roles: ['super-admin'],
|
||||
tenant: tenant1.id,
|
||||
},
|
||||
{
|
||||
roles: ['super-admin'],
|
||||
tenant: tenant2.id,
|
||||
},
|
||||
],
|
||||
username: 'tenant3',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
await findOrCreatePage({
|
||||
data: {
|
||||
slug: 'home',
|
||||
tenant: tenant1.id,
|
||||
title: 'Page for Tenant 1',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
await findOrCreatePage({
|
||||
data: {
|
||||
slug: 'home',
|
||||
tenant: tenant2.id,
|
||||
title: 'Page for Tenant 2',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
await findOrCreatePage({
|
||||
data: {
|
||||
slug: 'home',
|
||||
tenant: tenant3.id,
|
||||
title: 'Page for Tenant 3',
|
||||
},
|
||||
payload,
|
||||
})
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
run().catch(console.error)
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Where } from 'payload'
|
||||
|
||||
import configPromise from '@payload-config'
|
||||
import { getPayloadHMR } from '@payloadcms/next/utilities'
|
||||
import { headers as getHeaders } from 'next/headers.js'
|
||||
import { notFound, redirect } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
import { RenderPage } from '../../../components/RenderPage'
|
||||
|
||||
export default async function Page({ params }: { params: { slug?: string[]; tenant: string } }) {
|
||||
const headers = getHeaders()
|
||||
const payload = await getPayloadHMR({ 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) {
|
||||
redirect(
|
||||
`/${params.tenant}/login?redirect=${encodeURIComponent(
|
||||
`/${params.tenant}${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.slug': {
|
||||
equals: params.tenant,
|
||||
},
|
||||
},
|
||||
slugConstraint,
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const pageData = pageQuery.docs?.[0]
|
||||
|
||||
// The page with the provided slug could not be found
|
||||
if (!pageData) return notFound()
|
||||
|
||||
// The page was found, render the page with data
|
||||
return <RenderPage data={pageData} />
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
import { Login } from '../../../components/Login/client.page'
|
||||
|
||||
type RouteParams = {
|
||||
tenant: string
|
||||
}
|
||||
|
||||
export default function Page({ params }: { params: RouteParams }) {
|
||||
return <Login tenantSlug={params.tenant} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from 'react'
|
||||
|
||||
import Page from './[...slug]/page'
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,6 @@
|
||||
.multi-tenant {
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 10px
|
||||
}
|
||||
}
|
||||
18
examples/multi-tenant-single-domain/src/app/(app)/layout.tsx
Normal file
18
examples/multi-tenant-single-domain/src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'multi-tenant'
|
||||
|
||||
export const metadata = {
|
||||
description: 'Generated by Next.js',
|
||||
title: 'Next.js',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html className={baseClass} lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
|
||||
type Args = {
|
||||
params: {
|
||||
segments: string[]
|
||||
}
|
||||
searchParams: {
|
||||
[key: string]: string | string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const NotFound = ({ params, searchParams }: Args) => NotFoundPage({ config, params, searchParams })
|
||||
|
||||
export default NotFound
|
||||
@@ -0,0 +1,22 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
import config from '@payload-config'
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
|
||||
|
||||
type Args = {
|
||||
params: {
|
||||
segments: string[]
|
||||
}
|
||||
searchParams: {
|
||||
[key: string]: string | string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config, params, searchParams })
|
||||
|
||||
const Page = ({ params, searchParams }: Args) => RootPage({ config, params, searchParams })
|
||||
|
||||
export default Page
|
||||
@@ -0,0 +1,10 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY it because it could be re-written at any time. */
|
||||
import config from '@payload-config'
|
||||
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = REST_GET(config)
|
||||
export const POST = REST_POST(config)
|
||||
export const DELETE = REST_DELETE(config)
|
||||
export const PATCH = REST_PATCH(config)
|
||||
export const OPTIONS = REST_OPTIONS(config)
|
||||
@@ -0,0 +1,6 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY it because it could be re-written at any time. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||
|
||||
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||
@@ -0,0 +1,6 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
/* DO NOT MODIFY it because it could be re-written at any time. */
|
||||
import config from '@payload-config'
|
||||
import { GRAPHQL_POST } from '@payloadcms/next/routes'
|
||||
|
||||
export const POST = GRAPHQL_POST(config)
|
||||
@@ -0,0 +1,18 @@
|
||||
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||
import configPromise from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import { RootLayout } from "@payloadcms/next/layouts";
|
||||
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||
import React from "react";
|
||||
|
||||
import "./custom.scss";
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => (
|
||||
<RootLayout config={configPromise}>{children}</RootLayout>
|
||||
);
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
import type { FormEvent } from 'react'
|
||||
|
||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
const baseClass = 'loginPage'
|
||||
|
||||
type Props = {
|
||||
tenantSlug: string
|
||||
}
|
||||
export const Login = ({ tenantSlug }: Props) => {
|
||||
const usernameRef = React.useRef<HTMLInputElement>(null)
|
||||
const passwordRef = React.useRef<HTMLInputElement>(null)
|
||||
const router = useRouter()
|
||||
const routeParams = useParams()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
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 json = await actionRes.json()
|
||||
|
||||
if (actionRes.status === 200 && json.user) {
|
||||
const redirectTo = searchParams.get('redirect')
|
||||
if (redirectTo) {
|
||||
router.push(redirectTo)
|
||||
return
|
||||
} else {
|
||||
router.push(`/${routeParams.tenant}`)
|
||||
}
|
||||
} else if (actionRes.status === 400 && json?.errors?.[0]?.message) {
|
||||
window.alert(json.errors[0].message)
|
||||
} else {
|
||||
window.alert('Something went wrong, please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label>
|
||||
Username
|
||||
<input name="username" ref={usernameRef} type="text" />
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
Password
|
||||
<input name="password" ref={passwordRef} type="password" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.loginPage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 8px 16px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 4px;
|
||||
padding: 8px 20px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Page } from '@payload-types'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export const RenderPage = ({ data }: { data: Page }) => {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<h2>Here you can decide how you would like to render the page data!</h2>
|
||||
<code>{JSON.stringify(data)}</code>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const isSuperAdmin: Access = ({ req }) => {
|
||||
if (!req?.user) return false
|
||||
return Boolean(req.user.roles?.includes('super-admin'))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
import { parseCookies } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
|
||||
|
||||
export const byTenant: 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
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Access, Where } from 'payload'
|
||||
|
||||
import { parseCookies } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
|
||||
|
||||
export const externalReadAccess: 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,
|
||||
},
|
||||
}
|
||||
|
||||
// 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 {
|
||||
or: [
|
||||
publicPageConstraint,
|
||||
{
|
||||
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 {
|
||||
or: [
|
||||
publicPageConstraint,
|
||||
{
|
||||
tenant: {
|
||||
equals: selectedTenant,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no manually selected tenant,
|
||||
// but it is a super admin, give access to all
|
||||
if (superAdmin) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If not super admin,
|
||||
// but has access to tenants,
|
||||
// give access to only their own tenants
|
||||
if (tenantAccessIDs.length) {
|
||||
return {
|
||||
or: [
|
||||
publicPageConstraint,
|
||||
{
|
||||
tenant: {
|
||||
in: tenantAccessIDs,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Allow access to public pages
|
||||
return publicPageConstraint
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FieldHook } from 'payload'
|
||||
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
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
|
||||
|
||||
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
|
||||
const currentTenantID =
|
||||
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant
|
||||
const tenantIDToMatch = incomingTenantID || currentTenantID
|
||||
|
||||
const findDuplicatePages = await req.payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
tenant: {
|
||||
equals: tenantIDToMatch,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (findDuplicatePages.docs.length > 0 && req.user) {
|
||||
const tenantIDs = getTenantAccessIDs(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) {
|
||||
const attemptedTenantChange = await req.payload.findByID({
|
||||
id: tenantIDToMatch,
|
||||
collection: 'tenants',
|
||||
})
|
||||
|
||||
throw new ValidationError({
|
||||
errors: [
|
||||
{
|
||||
field: 'slug',
|
||||
message: `The "${attemptedTenantChange.name}" tenant already has a page with the slug "${value}". Slugs must be unique per tenant.`,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
throw new ValidationError({
|
||||
errors: [
|
||||
{
|
||||
field: 'slug',
|
||||
message: `A page with the slug ${value} already exists. Slug must be unique per tenant.`,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { tenantField } from '../../fields/TenantField'
|
||||
import { isPayloadAdminPanel } from '../../utilities/isPayloadAdminPanel'
|
||||
import { byTenant } from './access/byTenant'
|
||||
import { externalReadAccess } from './access/externalReadAccess'
|
||||
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
delete: byTenant,
|
||||
read: (args) => {
|
||||
// when viewing pages inside the admin panel
|
||||
// restrict access to the ones your user has access to
|
||||
if (isPayloadAdminPanel(args.req)) return byTenant(args)
|
||||
|
||||
// when viewing pages from outside the admin panel
|
||||
// you should be able to see your tenants and public tenants
|
||||
return externalReadAccess(args)
|
||||
},
|
||||
update: byTenant,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
defaultValue: 'home',
|
||||
hooks: {
|
||||
beforeValidate: [ensureUniqueSlug],
|
||||
},
|
||||
index: true,
|
||||
},
|
||||
tenantField,
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { getTenantAccessIDs } from '../../../utilities/getTenantAccessIDs'
|
||||
|
||||
export const tenantRead: Access = (args) => {
|
||||
const req = args.req
|
||||
|
||||
// Super admin can read all
|
||||
if (isSuperAdmin(args)) return true
|
||||
|
||||
const tenantIDs = getTenantAccessIDs(req.user)
|
||||
|
||||
// Allow public tenants to be read by anyone
|
||||
const publicConstraint = {
|
||||
public: {
|
||||
equals: true,
|
||||
},
|
||||
}
|
||||
|
||||
// If a user has tenant ID access,
|
||||
// return constraint to allow them to read those tenants
|
||||
if (tenantIDs.length) {
|
||||
return {
|
||||
or: [
|
||||
publicConstraint,
|
||||
{
|
||||
id: {
|
||||
in: tenantIDs,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return publicConstraint
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../access/isSuperAdmin'
|
||||
import { tenantRead } from './access/read'
|
||||
|
||||
export const Tenants: CollectionConfig = {
|
||||
slug: 'tenants',
|
||||
access: {
|
||||
create: isSuperAdmin,
|
||||
delete: isSuperAdmin,
|
||||
read: tenantRead,
|
||||
update: isSuperAdmin,
|
||||
},
|
||||
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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const isAccessingSelf: Access = ({ id, req }) => {
|
||||
if (!req?.user) return false
|
||||
return req.user.id === id
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { isAccessingSelf } from './isAccessingSelf'
|
||||
|
||||
export const isSuperAdminOrSelf: Access = (args) => isSuperAdmin(args) || isAccessingSelf(args)
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Collection, Endpoint } from 'payload'
|
||||
|
||||
import { headersWithCors } from '@payloadcms/next/utilities'
|
||||
import { APIError, generatePayloadCookie } from 'payload'
|
||||
|
||||
// A custom endpoint that can be reached by POST request
|
||||
// at: /api/users/external-users/login
|
||||
export const externalUsersLogin: Endpoint = {
|
||||
handler: async (req) => {
|
||||
let data: { [key: string]: string } = {}
|
||||
|
||||
try {
|
||||
if (typeof req.json === 'function') {
|
||||
data = await req.json()
|
||||
}
|
||||
} catch (error) {
|
||||
// swallow error, data is already empty object
|
||||
}
|
||||
const { password, tenantSlug, username } = data
|
||||
|
||||
if (!username || !password) {
|
||||
throw new APIError('Username and Password are required for login.', 400, null, true)
|
||||
}
|
||||
|
||||
const fullTenant = (
|
||||
await req.payload.find({
|
||||
collection: 'tenants',
|
||||
where: {
|
||||
slug: {
|
||||
equals: tenantSlug,
|
||||
},
|
||||
},
|
||||
})
|
||||
).docs[0]
|
||||
|
||||
const foundUser = await req.payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
or: [
|
||||
{
|
||||
and: [
|
||||
{
|
||||
email: {
|
||||
equals: username,
|
||||
},
|
||||
},
|
||||
{
|
||||
'tenants.tenant': {
|
||||
equals: fullTenant.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
and: [
|
||||
{
|
||||
username: {
|
||||
equals: username,
|
||||
},
|
||||
},
|
||||
{
|
||||
'tenants.tenant': {
|
||||
equals: fullTenant.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (foundUser.totalDocs > 0) {
|
||||
try {
|
||||
const loginAttempt = await req.payload.login({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: foundUser.docs[0].email,
|
||||
password,
|
||||
},
|
||||
req,
|
||||
})
|
||||
|
||||
if (loginAttempt?.token) {
|
||||
const collection: Collection = req.payload.collections['users']
|
||||
const cookie = generatePayloadCookie({
|
||||
collectionConfig: collection.config,
|
||||
payload: req.payload,
|
||||
token: loginAttempt.token,
|
||||
})
|
||||
|
||||
return Response.json(loginAttempt, {
|
||||
headers: headersWithCors({
|
||||
headers: new Headers({
|
||||
'Set-Cookie': cookie,
|
||||
}),
|
||||
req,
|
||||
}),
|
||||
status: 200,
|
||||
})
|
||||
}
|
||||
|
||||
throw new APIError(
|
||||
'Unable to login with the provided username and password.',
|
||||
400,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
} catch (e) {
|
||||
throw new APIError(
|
||||
'Unable to login with the provided username and password.',
|
||||
400,
|
||||
null,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
throw new APIError('Unable to login with the provided username and password.', 400, null, true)
|
||||
},
|
||||
method: 'post',
|
||||
path: '/external-users/login',
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { FieldHook } from 'payload'
|
||||
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
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
|
||||
|
||||
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
|
||||
const currentTenantID =
|
||||
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant
|
||||
const tenantIDToMatch = incomingTenantID || currentTenantID
|
||||
|
||||
const findDuplicateUsers = await req.payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
'tenants.tenant': {
|
||||
equals: tenantIDToMatch,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (findDuplicateUsers.docs.length > 0 && req.user) {
|
||||
const tenantIDs = getTenantAccessIDs(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) {
|
||||
const attemptedTenantChange = await req.payload.findByID({
|
||||
id: tenantIDToMatch,
|
||||
collection: 'tenants',
|
||||
})
|
||||
|
||||
throw new ValidationError({
|
||||
errors: [
|
||||
{
|
||||
field: 'username',
|
||||
message: `The "${attemptedTenantChange.name}" tenant already has a user with the username "${value}". Usernames must be unique per tenant.`,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
throw new ValidationError({
|
||||
errors: [
|
||||
{
|
||||
field: 'username',
|
||||
message: `A user with the username ${value} already exists. Usernames must be unique per tenant.`,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../access/isSuperAdmin'
|
||||
import { isSuperAdminOrSelf } from './access/isSuperAdminOrSelf'
|
||||
import { externalUsersLogin } from './endpoints/externalUsersLogin'
|
||||
import { ensureUniqueUsername } from './hooks/ensureUniqueUsername'
|
||||
|
||||
const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
access: {
|
||||
create: isSuperAdmin,
|
||||
delete: isSuperAdmin,
|
||||
read: (args) => {
|
||||
const { req } = args
|
||||
if (!req?.user) return false
|
||||
|
||||
if (isSuperAdmin(args)) return true
|
||||
|
||||
return {
|
||||
id: {
|
||||
equals: req.user.id,
|
||||
},
|
||||
}
|
||||
},
|
||||
update: isSuperAdminOrSelf,
|
||||
},
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
auth: true,
|
||||
endpoints: [externalUsersLogin],
|
||||
fields: [
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
defaultValue: ['user'],
|
||||
hasMany: true,
|
||||
options: ['super-admin', 'user'],
|
||||
},
|
||||
{
|
||||
name: 'tenants',
|
||||
type: 'array',
|
||||
access: {
|
||||
create: ({ req }) => {
|
||||
if (isSuperAdmin({ req })) return true
|
||||
return false
|
||||
},
|
||||
update: ({ req }) => {
|
||||
if (isSuperAdmin({ req })) return true
|
||||
return false
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'tenant',
|
||||
type: 'relationship',
|
||||
index: true,
|
||||
relationTo: 'tenants',
|
||||
saveToJWT: true,
|
||||
},
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
defaultValue: ['viewer'],
|
||||
hasMany: true,
|
||||
options: ['super-admin', 'viewer'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'username',
|
||||
type: 'text',
|
||||
hooks: {
|
||||
beforeValidate: [ensureUniqueUsername],
|
||||
},
|
||||
index: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default Users
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import type { Option } from '@payloadcms/ui/elements/ReactSelect'
|
||||
import type { OptionObject } from 'payload'
|
||||
|
||||
import { SelectInput, useAuth } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
import type { Tenant, User } from '../../../payload-types'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
export const TenantSelector = ({ initialCookie }: { initialCookie?: string }) => {
|
||||
const { user } = useAuth<User>()
|
||||
const [options, setOptions] = React.useState<OptionObject[]>([])
|
||||
const [value, setValue] = React.useState<string | undefined>(initialCookie)
|
||||
|
||||
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=/'
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchTenants = async () => {
|
||||
const res = await fetch(`/api/tenants?depth=0&limit=100&sort=name`, {
|
||||
credentials: 'include',
|
||||
}).then((res) => res.json())
|
||||
|
||||
setOptions(res.docs.map((doc: Tenant) => ({ label: doc.name, value: doc.id })))
|
||||
}
|
||||
|
||||
void fetchTenants()
|
||||
}, [])
|
||||
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (isSuperAdmin || tenantIDs.length > 1) {
|
||||
return (
|
||||
<div className="tenant-selector">
|
||||
<SelectInput
|
||||
label="Select a tenant"
|
||||
name="setTenant"
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
path="setTenant"
|
||||
value={value}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.tenant-selector {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { cookies as getCookies } from 'next/headers'
|
||||
import React from 'react'
|
||||
|
||||
import { TenantSelector } from './index.client'
|
||||
|
||||
export const TenantSelectorRSC = () => {
|
||||
const cookies = getCookies()
|
||||
return <TenantSelector initialCookie={cookies.get('payload-tenant')?.value} />
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
import type { User } from 'payload/generated-types'
|
||||
|
||||
import { RelationshipField, useAuth, useFieldProps } from '@payloadcms/ui'
|
||||
import React from 'react'
|
||||
|
||||
export const TenantFieldComponent = () => {
|
||||
const { user } = useAuth<User>()
|
||||
const { path, readOnly } = useFieldProps()
|
||||
|
||||
if (user) {
|
||||
if ((user.tenants && user.tenants.length > 1) || user?.roles?.includes('super-admin')) {
|
||||
return (
|
||||
<RelationshipField
|
||||
label="Tenant"
|
||||
name={path}
|
||||
path={path}
|
||||
readOnly={readOnly}
|
||||
relationTo="tenants"
|
||||
required
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Field } from 'payload'
|
||||
|
||||
import { isSuperAdmin } from '../../access/isSuperAdmin'
|
||||
import { tenantFieldUpdate } from './access/update'
|
||||
import { TenantFieldComponent } from './components/Field'
|
||||
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: TenantFieldComponent,
|
||||
},
|
||||
position: 'sidebar',
|
||||
},
|
||||
hasMany: false,
|
||||
hooks: {
|
||||
beforeValidate: [autofillTenant],
|
||||
},
|
||||
index: true,
|
||||
relationTo: 'tenants',
|
||||
required: true,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { User } from '../../payload-types'
|
||||
|
||||
export const getTenantAccessIDs = (user: User | null): string[] => {
|
||||
if (!user) return []
|
||||
return user?.tenants?.reduce((acc: string[], { tenant }) => {
|
||||
if (tenant) {
|
||||
acc.push(typeof tenant === 'string' ? tenant : tenant.id)
|
||||
}
|
||||
return acc
|
||||
}, []) || []
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
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}`)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Request } from 'express'
|
||||
|
||||
export function parseCookies(req: Request): { [key: string]: string } {
|
||||
const list = {}
|
||||
const rc = req.headers.cookie
|
||||
|
||||
if (rc) {
|
||||
rc.split(';').forEach((cookie) => {
|
||||
const parts = cookie.split('=')
|
||||
const key = parts.shift().trim()
|
||||
const encodedValue = parts.join('=')
|
||||
|
||||
try {
|
||||
const decodedValue = decodeURI(encodedValue)
|
||||
list[key] = decodedValue
|
||||
} catch (e) {
|
||||
// swallow e
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
134
examples/multi-tenant-single-domain/src/payload-types.ts
Normal file
134
examples/multi-tenant-single-domain/src/payload-types.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/* 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;
|
||||
};
|
||||
globals: {};
|
||||
locale: null;
|
||||
user: User & {
|
||||
collection: 'users';
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
};
|
||||
login: {
|
||||
password: string;
|
||||
email: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
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 | null) | Tenant;
|
||||
roles?: ('super-admin' | 'viewer')[] | null;
|
||||
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 {}
|
||||
}
|
||||
34
examples/multi-tenant-single-domain/src/payload.config.ts
Normal file
34
examples/multi-tenant-single-domain/src/payload.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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 './cms/collections/Pages'
|
||||
import { Tenants } from './cms/collections/Tenants'
|
||||
import Users from './cms/collections/Users'
|
||||
import { TenantSelectorRSC } from './cms/components/TenantSelector/index'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
components: {
|
||||
afterNavLinks: [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'),
|
||||
},
|
||||
})
|
||||
47
examples/multi-tenant-single-domain/tsconfig.json
Normal file
47
examples/multi-tenant-single-domain/tsconfig.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
6280
examples/multi-tenant-single-domain/yarn.lock
Normal file
6280
examples/multi-tenant-single-domain/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user