feat: initial commit
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URI=mongodb://127.0.0.1/payload-example-multi-tenant
|
||||||
|
POSTGRES_URL=postgres://127.0.0.1:5432/payload-example-multi-tenant
|
||||||
|
PAYLOAD_SECRET=PAYLOAD_MULTI_TENANT_EXAMPLE_SECRET_KEY
|
||||||
|
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||||
|
SEED_DB=true
|
||||||
4
.eslintrc.cjs
Normal file
4
.eslintrc.cjs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
extends: ['@payloadcms'],
|
||||||
|
}
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
build
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
.env
|
||||||
24
.swcrc
Normal file
24
.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
README.md
Normal file
92
README.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# Payload Multi-Tenant Example
|
||||||
|
|
||||||
|
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. Run the following command to create a project from the example:
|
||||||
|
|
||||||
|
- `npx create-payload-app --example multi-tenant`
|
||||||
|
|
||||||
|
2. `cp .env.example .env` to copy the example environment variables
|
||||||
|
|
||||||
|
3. `pnpm dev`, `yarn dev` or `npm run dev` to start the server
|
||||||
|
- Press `y` when prompted to seed the database
|
||||||
|
4. `open http://localhost:3000` to access the home page
|
||||||
|
5. `open http://localhost:3000/admin` to access the admin panel
|
||||||
|
|
||||||
|
### Default users
|
||||||
|
|
||||||
|
The seed script seeds 3 tenants.
|
||||||
|
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 encompasses 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/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.
|
||||||
|
|
||||||
|
**Domain-based Tenant Setting**:
|
||||||
|
|
||||||
|
This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.localhost: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.
|
||||||
|
|
||||||
|
For the domain portion of the example to function properly, you will need to add the following entries to your system's `/etc/hosts` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 gold.localhost silver.localhost bronze.localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
- #### Pages
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Basic role-based access control is set up 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.
|
||||||
|
|
||||||
|
> 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
next-env.d.ts
vendored
Normal file
5
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/app/api-reference/config/typescript for more information.
|
||||||
22
next.config.mjs
Normal file
22
next.config.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { withPayload } from '@payloadcms/next/withPayload'
|
||||||
|
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// Your Next.js config here
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/((?!admin|api))tenant-domains/:path*',
|
||||||
|
destination: '/tenant-domains/:tenant/:path*',
|
||||||
|
has: [
|
||||||
|
{
|
||||||
|
type: 'host',
|
||||||
|
value: '(?<tenant>.*)',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withPayload(nextConfig)
|
||||||
48
package.json
Normal file
48
package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "meal-planner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "An example of a multi tenant application with Payload",
|
||||||
|
"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",
|
||||||
|
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
|
||||||
|
"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.65.0",
|
||||||
|
"@payloadcms/db-postgres": "3.65.0",
|
||||||
|
"@payloadcms/next": "3.65.0",
|
||||||
|
"@payloadcms/plugin-multi-tenant": "3.65.0",
|
||||||
|
"@payloadcms/richtext-lexical": "3.65.0",
|
||||||
|
"@payloadcms/ui": "3.65.0",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"graphql": "^16.9.0",
|
||||||
|
"next": "^15.2.3",
|
||||||
|
"payload": "3.65.0",
|
||||||
|
"qs-esm": "7.0.2",
|
||||||
|
"react": "19.0.0",
|
||||||
|
"react-dom": "19.0.0",
|
||||||
|
"sharp": "0.32.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@payloadcms/graphql": "latest",
|
||||||
|
"@swc/core": "^1.6.13",
|
||||||
|
"@types/react": "19.0.1",
|
||||||
|
"@types/react-dom": "19.0.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-next": "^15.0.0",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "5.5.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.20.2 || >=20.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
7770
pnpm-lock.yaml
generated
Normal file
7770
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
src/access/isSuperAdmin.ts
Normal file
10
src/access/isSuperAdmin.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Access } from 'payload'
|
||||||
|
import { User } from '../payload-types'
|
||||||
|
|
||||||
|
export const isSuperAdminAccess: Access = ({ req }): boolean => {
|
||||||
|
return isSuperAdmin(req.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSuperAdmin = (user: User | null): boolean => {
|
||||||
|
return Boolean(user?.roles?.includes('super-admin'))
|
||||||
|
}
|
||||||
6
src/app/(app)/index.scss
Normal file
6
src/app/(app)/index.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.multi-tenant {
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/(app)/layout.tsx
Normal file
19
src/app/(app)/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const baseClass = 'multi-tenant'
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
description: 'Generated by Next.js',
|
||||||
|
title: 'Next.js',
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html className={baseClass} lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
src/app/(app)/page.tsx
Normal file
30
src/app/(app)/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export default async ({ params: paramsPromise }: { params: Promise<{ slug: string[] }> }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Multi-Tenant Example</h1>
|
||||||
|
<p>
|
||||||
|
This multi-tenant example allows you to explore multi-tenancy with domains and with slugs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Domains</h2>
|
||||||
|
<p>When you visit a tenant by domain, the domain is used to determine the tenant.</p>
|
||||||
|
<p>
|
||||||
|
For example, visiting{' '}
|
||||||
|
<a href="http://gold.localhost:3000/tenant-domains/login">
|
||||||
|
http://gold.localhost:3000/tenant-domains/login
|
||||||
|
</a>{' '}
|
||||||
|
will show the tenant with the domain "gold.localhost".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Slugs</h2>
|
||||||
|
<p>When you visit a tenant by slug, the slug is used to determine the tenant.</p>
|
||||||
|
<p>
|
||||||
|
For example, visiting{' '}
|
||||||
|
<a href="http://localhost:3000/tenant-slugs/silver/login">
|
||||||
|
http://localhost:3000/tenant-slugs/silver/login
|
||||||
|
</a>{' '}
|
||||||
|
will show the tenant with the slug "silver".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
111
src/app/(app)/tenant-domains/[tenant]/[...slug]/page.tsx
Normal file
111
src/app/(app)/tenant-domains/[tenant]/[...slug]/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import type { Where } from 'payload'
|
||||||
|
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import { headers as getHeaders } from 'next/headers'
|
||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { RenderPage } from '../../../../components/RenderPage'
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
|
export default async function Page({
|
||||||
|
params: paramsPromise,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug?: string[]; tenant: string }>
|
||||||
|
}) {
|
||||||
|
const params = await paramsPromise
|
||||||
|
let slug = undefined
|
||||||
|
if (params?.slug) {
|
||||||
|
// remove the domain route param
|
||||||
|
params.slug.splice(0, 1)
|
||||||
|
slug = params.slug
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = await getHeaders()
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const { user } = await payload.auth({ headers })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantsQuery = await payload.find({
|
||||||
|
collection: 'tenants',
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
where: {
|
||||||
|
domain: {
|
||||||
|
equals: params.tenant,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// If no tenant is found, the user does not have access
|
||||||
|
// Show the login view
|
||||||
|
if (tenantsQuery.docs.length === 0) {
|
||||||
|
redirect(
|
||||||
|
`/tenant-domains/login?redirect=${encodeURIComponent(
|
||||||
|
`/tenant-domains${slug ? `/${slug.join('/')}` : ''}`,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If the query fails, it means the user did not have access to query on the domain field
|
||||||
|
// Show the login view
|
||||||
|
redirect(
|
||||||
|
`/tenant-domains/login?redirect=${encodeURIComponent(
|
||||||
|
`/tenant-domains${slug ? `/${slug.join('/')}` : ''}`,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const slugConstraint: Where = slug
|
||||||
|
? {
|
||||||
|
slug: {
|
||||||
|
equals: slug.join('/'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
equals: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
equals: 'home',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
exists: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageQuery = await payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
where: {
|
||||||
|
and: [
|
||||||
|
{
|
||||||
|
'tenant.domain': {
|
||||||
|
equals: params.tenant,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
slugConstraint,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageData = pageQuery.docs?.[0]
|
||||||
|
|
||||||
|
// The page with the provided slug could not be found
|
||||||
|
if (!pageData) {
|
||||||
|
return notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page was found, render the page with data
|
||||||
|
return <RenderPage data={pageData} />
|
||||||
|
}
|
||||||
14
src/app/(app)/tenant-domains/[tenant]/login/page.tsx
Normal file
14
src/app/(app)/tenant-domains/[tenant]/login/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Login } from '../../../../components/Login/client.page'
|
||||||
|
|
||||||
|
type RouteParams = {
|
||||||
|
tenant: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
|
export default async function Page({ params: paramsPromise }: { params: Promise<RouteParams> }) {
|
||||||
|
const params = await paramsPromise
|
||||||
|
|
||||||
|
return <Login tenantDomain={params.tenant} />
|
||||||
|
}
|
||||||
3
src/app/(app)/tenant-domains/[tenant]/page.tsx
Normal file
3
src/app/(app)/tenant-domains/[tenant]/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Page from './[...slug]/page'
|
||||||
|
|
||||||
|
export default Page
|
||||||
106
src/app/(app)/tenant-slugs/[tenant]/[...slug]/page.tsx
Normal file
106
src/app/(app)/tenant-slugs/[tenant]/[...slug]/page.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import type { Where } from 'payload'
|
||||||
|
|
||||||
|
import configPromise from '@payload-config'
|
||||||
|
import { headers as getHeaders } from 'next/headers'
|
||||||
|
import { notFound, redirect } from 'next/navigation'
|
||||||
|
import { getPayload } from 'payload'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { RenderPage } from '../../../../components/RenderPage'
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
|
export default async function Page({
|
||||||
|
params: paramsPromise,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ slug?: string[]; tenant: string }>
|
||||||
|
}) {
|
||||||
|
const params = await paramsPromise
|
||||||
|
|
||||||
|
const headers = await getHeaders()
|
||||||
|
const payload = await getPayload({ config: configPromise })
|
||||||
|
const { user } = await payload.auth({ headers })
|
||||||
|
|
||||||
|
const slug = params?.slug
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantsQuery = await payload.find({
|
||||||
|
collection: 'tenants',
|
||||||
|
overrideAccess: false,
|
||||||
|
user,
|
||||||
|
where: {
|
||||||
|
slug: {
|
||||||
|
equals: params.tenant,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// If no tenant is found, the user does not have access
|
||||||
|
// Show the login view
|
||||||
|
if (tenantsQuery.docs.length === 0) {
|
||||||
|
redirect(
|
||||||
|
`/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent(
|
||||||
|
`/tenant-slugs/${params.tenant}${slug ? `/${slug.join('/')}` : ''}`,
|
||||||
|
)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If the query fails, it means the user did not have access to query on the slug field
|
||||||
|
// Show the login view
|
||||||
|
redirect(
|
||||||
|
`/tenant-slugs/${params.tenant}/login?redirect=${encodeURIComponent(
|
||||||
|
`/tenant-slugs/${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} />
|
||||||
|
}
|
||||||
14
src/app/(app)/tenant-slugs/[tenant]/login/page.tsx
Normal file
14
src/app/(app)/tenant-slugs/[tenant]/login/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { Login } from '../../../../components/Login/client.page'
|
||||||
|
|
||||||
|
type RouteParams = {
|
||||||
|
tenant: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
|
export default async function Page({ params: paramsPromise }: { params: Promise<RouteParams> }) {
|
||||||
|
const params = await paramsPromise
|
||||||
|
|
||||||
|
return <Login tenantSlug={params.tenant} />
|
||||||
|
}
|
||||||
3
src/app/(app)/tenant-slugs/[tenant]/page.tsx
Normal file
3
src/app/(app)/tenant-slugs/[tenant]/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Page from './[...slug]/page'
|
||||||
|
|
||||||
|
export default Page
|
||||||
25
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
25
src/app/(payload)/admin/[[...segments]]/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||||
|
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views'
|
||||||
|
|
||||||
|
import { importMap } from '../importMap.js'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{
|
||||||
|
segments: string[]
|
||||||
|
}>
|
||||||
|
searchParams: Promise<{
|
||||||
|
[key: string]: string | string[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||||
|
generatePageMetadata({ config, params, searchParams })
|
||||||
|
|
||||||
|
const NotFound = ({ params, searchParams }: Args) =>
|
||||||
|
NotFoundPage({ config, importMap, params, searchParams })
|
||||||
|
|
||||||
|
export default NotFound
|
||||||
25
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
25
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||||
|
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||||
|
import type { Metadata } from 'next'
|
||||||
|
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { generatePageMetadata, RootPage } from '@payloadcms/next/views'
|
||||||
|
|
||||||
|
import { importMap } from '../importMap.js'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
params: Promise<{
|
||||||
|
segments: string[]
|
||||||
|
}>
|
||||||
|
searchParams: Promise<{
|
||||||
|
[key: string]: string | string[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
|
||||||
|
generatePageMetadata({ config, params, searchParams })
|
||||||
|
|
||||||
|
const Page = ({ params, searchParams }: Args) =>
|
||||||
|
RootPage({ config, importMap, params, searchParams })
|
||||||
|
|
||||||
|
export default Page
|
||||||
9
src/app/(payload)/admin/importMap.js
Normal file
9
src/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||||
|
import { TenantSelector as TenantSelector_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
|
||||||
|
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||||
|
|
||||||
|
export const importMap = {
|
||||||
|
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
|
||||||
|
"@payloadcms/plugin-multi-tenant/client#TenantSelector": TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
|
||||||
|
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
|
||||||
|
}
|
||||||
10
src/app/(payload)/api/[...slug]/route.ts
Normal file
10
src/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||||
|
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN 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)
|
||||||
6
src/app/(payload)/api/graphql-playground/route.ts
Normal file
6
src/app/(payload)/api/graphql-playground/route.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||||
|
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
|
||||||
|
|
||||||
|
export const GET = GRAPHQL_PLAYGROUND_GET(config)
|
||||||
8
src/app/(payload)/api/graphql/route.ts
Normal file
8
src/app/(payload)/api/graphql/route.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||||
|
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||||
|
import config from '@payload-config'
|
||||||
|
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
|
||||||
|
|
||||||
|
export const POST = GRAPHQL_POST(config)
|
||||||
|
|
||||||
|
export const OPTIONS = REST_OPTIONS(config)
|
||||||
0
src/app/(payload)/custom.scss
Normal file
0
src/app/(payload)/custom.scss
Normal file
32
src/app/(payload)/layout.tsx
Normal file
32
src/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
|
||||||
|
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
|
||||||
|
import type { ServerFunctionClient } from 'payload'
|
||||||
|
|
||||||
|
import config from '@payload-config'
|
||||||
|
import '@payloadcms/next/css'
|
||||||
|
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import { importMap } from './admin/importMap.js'
|
||||||
|
import './custom.scss'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverFunction: ServerFunctionClient = async function (args) {
|
||||||
|
'use server'
|
||||||
|
return handleServerFunctions({
|
||||||
|
...args,
|
||||||
|
config,
|
||||||
|
importMap,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout = ({ children }: Args) => (
|
||||||
|
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||||
|
{children}
|
||||||
|
</RootLayout>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Layout
|
||||||
83
src/app/components/Login/client.page.tsx
Normal file
83
src/app/components/Login/client.page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client'
|
||||||
|
import type { FormEvent } from 'react'
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const baseClass = 'loginPage'
|
||||||
|
|
||||||
|
// go to /tenant1/home
|
||||||
|
// redirects to /tenant1/login?redirect=%2Ftenant1%2Fhome
|
||||||
|
// login, uses slug to set payload-tenant cookie
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tenantSlug?: string
|
||||||
|
tenantDomain?: string
|
||||||
|
}
|
||||||
|
export const Login = ({ tenantSlug, tenantDomain }: Props) => {
|
||||||
|
const usernameRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
const passwordRef = React.useRef<HTMLInputElement>(null)
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!usernameRef?.current?.value || !passwordRef?.current?.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const actionRes = await fetch('/api/users/external-users/login', {
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: passwordRef.current.value,
|
||||||
|
tenantSlug,
|
||||||
|
tenantDomain,
|
||||||
|
username: usernameRef.current.value,
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
method: 'post',
|
||||||
|
})
|
||||||
|
const json = await actionRes.json()
|
||||||
|
|
||||||
|
if (actionRes.status === 200 && json.user) {
|
||||||
|
const redirectTo = searchParams.get('redirect')
|
||||||
|
if (redirectTo) {
|
||||||
|
router.push(redirectTo)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
if (tenantDomain) {
|
||||||
|
router.push('/tenant-domains')
|
||||||
|
} else {
|
||||||
|
router.push(`/tenant-slugs/${tenantSlug}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (actionRes.status === 400 && json?.errors?.[0]?.message) {
|
||||||
|
window.alert(json.errors[0].message)
|
||||||
|
} 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/app/components/Login/index.scss
Normal file
29
src/app/components/Login/index.scss
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/components/RenderPage/index.tsx
Normal file
16
src/app/components/RenderPage/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Page } from '@payload-types'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export const RenderPage = ({ data }: { data: Page }) => {
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<form action="/api/users/logout" method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
<h2>Here you can decide how you would like to render the page data!</h2>
|
||||||
|
|
||||||
|
<code>{JSON.stringify(data)}</code>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/collections/Pages/access/superAdminOrTenantAdmin.ts
Normal file
25
src/collections/Pages/access/superAdminOrTenantAdmin.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { getUserTenantIDs } from '@/utilities/getUserTenantIDs'
|
||||||
|
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||||
|
import { Access } from 'payload'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant admins and super admins can will be allowed access
|
||||||
|
*/
|
||||||
|
export const superAdminOrTenantAdminAccess: Access = ({ req }) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuperAdmin(req.user)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||||
|
const requestedTenant = req?.data?.tenant
|
||||||
|
|
||||||
|
if (requestedTenant && adminTenantAccessIDs.includes(requestedTenant)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
72
src/collections/Pages/hooks/ensureUniqueSlug.ts
Normal file
72
src/collections/Pages/hooks/ensureUniqueSlug.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import type { FieldHook, Where } from 'payload'
|
||||||
|
|
||||||
|
import { ValidationError } from 'payload'
|
||||||
|
|
||||||
|
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||||
|
import { extractID } from '@/utilities/extractID'
|
||||||
|
|
||||||
|
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
|
||||||
|
// if value is unchanged, skip validation
|
||||||
|
if (originalDoc.slug === value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints: Where[] = [
|
||||||
|
{
|
||||||
|
slug: {
|
||||||
|
equals: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const incomingTenantID = extractID(data?.tenant)
|
||||||
|
const currentTenantID = extractID(originalDoc?.tenant)
|
||||||
|
const tenantIDToMatch = incomingTenantID || currentTenantID
|
||||||
|
|
||||||
|
if (tenantIDToMatch) {
|
||||||
|
constraints.push({
|
||||||
|
tenant: {
|
||||||
|
equals: tenantIDToMatch,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const findDuplicatePages = await req.payload.find({
|
||||||
|
collection: 'pages',
|
||||||
|
where: {
|
||||||
|
and: constraints,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (findDuplicatePages.docs.length > 0 && req.user) {
|
||||||
|
const tenantIDs = getUserTenantIDs(req.user)
|
||||||
|
// if the user is an admin or has access to more than 1 tenant
|
||||||
|
// provide a more specific error message
|
||||||
|
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
|
||||||
|
const attemptedTenantChange = await req.payload.findByID({
|
||||||
|
id: tenantIDToMatch,
|
||||||
|
collection: 'tenants',
|
||||||
|
})
|
||||||
|
|
||||||
|
throw new ValidationError({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `The "${attemptedTenantChange.name}" tenant already has a page with the slug "${value}". Slugs must be unique per tenant.`,
|
||||||
|
path: 'slug',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ValidationError({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `A page with the slug ${value} already exists. Slug must be unique per tenant.`,
|
||||||
|
path: 'slug',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
32
src/collections/Pages/index.ts
Normal file
32
src/collections/Pages/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
|
||||||
|
import { superAdminOrTenantAdminAccess } from '@/collections/Pages/access/superAdminOrTenantAdmin'
|
||||||
|
|
||||||
|
export const Pages: CollectionConfig = {
|
||||||
|
slug: 'pages',
|
||||||
|
access: {
|
||||||
|
create: superAdminOrTenantAdminAccess,
|
||||||
|
delete: superAdminOrTenantAdminAccess,
|
||||||
|
read: () => true,
|
||||||
|
update: superAdminOrTenantAdminAccess,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'title',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'slug',
|
||||||
|
type: 'text',
|
||||||
|
defaultValue: 'home',
|
||||||
|
hooks: {
|
||||||
|
beforeValidate: [ensureUniqueSlug],
|
||||||
|
},
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
39
src/collections/Tenants/access/byTenant.ts
Normal file
39
src/collections/Tenants/access/byTenant.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Access } from 'payload'
|
||||||
|
|
||||||
|
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||||
|
|
||||||
|
export const filterByTenantRead: Access = (args) => {
|
||||||
|
// Allow public tenants to be read by anyone
|
||||||
|
if (!args.req.user) {
|
||||||
|
return {
|
||||||
|
allowPublicRead: {
|
||||||
|
equals: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const canMutateTenant: Access = ({ req }) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuperAdmin(req.user)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
in:
|
||||||
|
req.user?.tenants
|
||||||
|
?.map(({ roles, tenant }) =>
|
||||||
|
roles?.includes('tenant-admin')
|
||||||
|
? tenant && (typeof tenant === 'string' ? tenant : tenant.id)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
.filter(Boolean) || [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/collections/Tenants/access/updateAndDelete.ts
Normal file
19
src/collections/Tenants/access/updateAndDelete.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||||
|
import { getUserTenantIDs } from '@/utilities/getUserTenantIDs'
|
||||||
|
import { Access } from 'payload'
|
||||||
|
|
||||||
|
export const updateAndDeleteAccess: Access = ({ req }) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuperAdmin(req.user)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: {
|
||||||
|
in: getUserTenantIDs(req.user, 'tenant-admin'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/collections/Tenants/index.ts
Normal file
51
src/collections/Tenants/index.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
import { isSuperAdminAccess } from '@/access/isSuperAdmin'
|
||||||
|
import { updateAndDeleteAccess } from './access/updateAndDelete'
|
||||||
|
|
||||||
|
export const Tenants: CollectionConfig = {
|
||||||
|
slug: 'tenants',
|
||||||
|
access: {
|
||||||
|
create: isSuperAdminAccess,
|
||||||
|
delete: updateAndDeleteAccess,
|
||||||
|
read: ({ req }) => Boolean(req.user),
|
||||||
|
update: updateAndDeleteAccess,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'name',
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'domain',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Used for domain-based tenant handling',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'slug',
|
||||||
|
type: 'text',
|
||||||
|
admin: {
|
||||||
|
description: 'Used for url paths, example: /tenant-slug/page-slug',
|
||||||
|
},
|
||||||
|
index: true,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'allowPublicRead',
|
||||||
|
type: 'checkbox',
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
'If checked, logging in is not required to read. Useful for building public pages.',
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
defaultValue: false,
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
35
src/collections/Users/access/create.ts
Normal file
35
src/collections/Users/access/create.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Access } from 'payload'
|
||||||
|
|
||||||
|
import type { Tenant, User } from '../../../payload-types'
|
||||||
|
|
||||||
|
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||||
|
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||||
|
|
||||||
|
export const createAccess: Access<User> = ({ req }) => {
|
||||||
|
if (!req.user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuperAdmin(req.user)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSuperAdmin(req.user) && req.data?.roles?.includes('super-admin')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||||
|
|
||||||
|
const requestedTenants: Tenant['id'][] =
|
||||||
|
req.data?.tenants?.map((t: { tenant: Tenant['id'] }) => t.tenant) ?? []
|
||||||
|
|
||||||
|
const hasAccessToAllRequestedTenants = requestedTenants.every((tenantID) =>
|
||||||
|
adminTenantAccessIDs.includes(tenantID),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (hasAccessToAllRequestedTenants) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
5
src/collections/Users/access/isAccessingSelf.ts
Normal file
5
src/collections/Users/access/isAccessingSelf.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { User } from '@/payload-types'
|
||||||
|
|
||||||
|
export const isAccessingSelf = ({ id, user }: { user?: User; id?: string | number }): boolean => {
|
||||||
|
return user ? Boolean(user.id === id) : false
|
||||||
|
}
|
||||||
56
src/collections/Users/access/read.ts
Normal file
56
src/collections/Users/access/read.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { User } from '@/payload-types'
|
||||||
|
import type { Access, Where } from 'payload'
|
||||||
|
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
||||||
|
|
||||||
|
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||||
|
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||||
|
import { isAccessingSelf } from './isAccessingSelf'
|
||||||
|
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
|
||||||
|
|
||||||
|
export const readAccess: Access<User> = ({ req, id }) => {
|
||||||
|
if (!req?.user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAccessingSelf({ id, user: req.user })) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const superAdmin = isSuperAdmin(req.user)
|
||||||
|
const selectedTenant = getTenantFromCookie(
|
||||||
|
req.headers,
|
||||||
|
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
|
||||||
|
)
|
||||||
|
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||||
|
|
||||||
|
if (selectedTenant) {
|
||||||
|
// If it's a super admin, or they have access to the tenant ID set in cookie
|
||||||
|
const hasTenantAccess = adminTenantAccessIDs.some((id) => id === selectedTenant)
|
||||||
|
if (superAdmin || hasTenantAccess) {
|
||||||
|
return {
|
||||||
|
'tenants.tenant': {
|
||||||
|
equals: selectedTenant,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (superAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
or: [
|
||||||
|
{
|
||||||
|
id: {
|
||||||
|
equals: req.user.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'tenants.tenant': {
|
||||||
|
in: adminTenantAccessIDs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Where
|
||||||
|
}
|
||||||
31
src/collections/Users/access/updateAndDelete.ts
Normal file
31
src/collections/Users/access/updateAndDelete.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Access } from 'payload'
|
||||||
|
|
||||||
|
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||||
|
import { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||||
|
import { isAccessingSelf } from './isAccessingSelf'
|
||||||
|
|
||||||
|
export const updateAndDeleteAccess: Access = ({ req, id }) => {
|
||||||
|
const { user } = req
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuperAdmin(user) || isAccessingSelf({ user, id })) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constrains update and delete access to users that belong
|
||||||
|
* to the same tenant as the tenant-admin making the request
|
||||||
|
*
|
||||||
|
* You may want to take this a step further with a beforeChange
|
||||||
|
* hook to ensure that the a tenant-admin can only remove users
|
||||||
|
* from their own tenant in the tenants array.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
'tenants.tenant': {
|
||||||
|
in: getUserTenantIDs(user, 'tenant-admin'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/collections/Users/endpoints/externalUsersLogin.ts
Normal file
130
src/collections/Users/endpoints/externalUsersLogin.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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, tenantDomain, 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: tenantDomain
|
||||||
|
? {
|
||||||
|
domain: {
|
||||||
|
equals: tenantDomain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
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 as { [key: string]: Collection })[
|
||||||
|
'users'
|
||||||
|
]
|
||||||
|
const cookie = generatePayloadCookie({
|
||||||
|
collectionAuthConfig: collection.config.auth,
|
||||||
|
cookiePrefix: req.payload.config.cookiePrefix,
|
||||||
|
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',
|
||||||
|
}
|
||||||
76
src/collections/Users/hooks/ensureUniqueUsername.ts
Normal file
76
src/collections/Users/hooks/ensureUniqueUsername.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { FieldHook, Where } from 'payload'
|
||||||
|
|
||||||
|
import { ValidationError } from 'payload'
|
||||||
|
|
||||||
|
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||||
|
import { extractID } from '@/utilities/extractID'
|
||||||
|
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
||||||
|
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
|
||||||
|
|
||||||
|
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
|
||||||
|
// if value is unchanged, skip validation
|
||||||
|
if (originalDoc.username === value) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints: Where[] = [
|
||||||
|
{
|
||||||
|
username: {
|
||||||
|
equals: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const selectedTenant = getTenantFromCookie(
|
||||||
|
req.headers,
|
||||||
|
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (selectedTenant) {
|
||||||
|
constraints.push({
|
||||||
|
'tenants.tenant': {
|
||||||
|
equals: selectedTenant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const findDuplicateUsers = await req.payload.find({
|
||||||
|
collection: 'users',
|
||||||
|
where: {
|
||||||
|
and: constraints,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (findDuplicateUsers.docs.length > 0 && req.user) {
|
||||||
|
const tenantIDs = getUserTenantIDs(req.user)
|
||||||
|
// if the user is an admin or has access to more than 1 tenant
|
||||||
|
// provide a more specific error message
|
||||||
|
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
|
||||||
|
const attemptedTenantChange = await req.payload.findByID({
|
||||||
|
// @ts-ignore - selectedTenant will match DB ID type
|
||||||
|
id: selectedTenant,
|
||||||
|
collection: 'tenants',
|
||||||
|
})
|
||||||
|
|
||||||
|
throw new ValidationError({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `The "${attemptedTenantChange.name}" tenant already has a user with the username "${value}". Usernames must be unique per tenant.`,
|
||||||
|
path: 'username',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ValidationError({
|
||||||
|
errors: [
|
||||||
|
{
|
||||||
|
message: `A user with the username ${value} already exists. Usernames must be unique per tenant.`,
|
||||||
|
path: 'username',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
39
src/collections/Users/hooks/setCookieBasedOnDomain.ts
Normal file
39
src/collections/Users/hooks/setCookieBasedOnDomain.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { CollectionAfterLoginHook } from 'payload'
|
||||||
|
|
||||||
|
import { mergeHeaders, generateCookie, getCookieExpiration } from 'payload'
|
||||||
|
|
||||||
|
export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, user }) => {
|
||||||
|
const relatedOrg = await req.payload.find({
|
||||||
|
collection: 'tenants',
|
||||||
|
depth: 0,
|
||||||
|
limit: 1,
|
||||||
|
where: {
|
||||||
|
domain: {
|
||||||
|
equals: 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: String(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
|
||||||
|
}
|
||||||
119
src/collections/Users/index.ts
Normal file
119
src/collections/Users/index.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { CollectionConfig } from 'payload'
|
||||||
|
|
||||||
|
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 { isSuperAdmin } from '@/access/isSuperAdmin'
|
||||||
|
import { setCookieBasedOnDomain } from './hooks/setCookieBasedOnDomain'
|
||||||
|
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields'
|
||||||
|
|
||||||
|
const defaultTenantArrayField = tenantsArrayField({
|
||||||
|
tenantsArrayFieldName: 'tenants',
|
||||||
|
tenantsArrayTenantFieldName: 'tenant',
|
||||||
|
tenantsCollectionSlug: 'tenants',
|
||||||
|
arrayFieldAccess: {},
|
||||||
|
tenantFieldAccess: {},
|
||||||
|
rowFields: [
|
||||||
|
{
|
||||||
|
name: 'roles',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: ['tenant-viewer'],
|
||||||
|
hasMany: true,
|
||||||
|
options: ['tenant-admin', 'tenant-viewer'],
|
||||||
|
required: true,
|
||||||
|
access: {
|
||||||
|
update: ({ req }) => {
|
||||||
|
const { user } = req
|
||||||
|
if (!user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuperAdmin(user)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const Users: CollectionConfig = {
|
||||||
|
slug: 'users',
|
||||||
|
access: {
|
||||||
|
create: createAccess,
|
||||||
|
delete: updateAndDeleteAccess,
|
||||||
|
read: readAccess,
|
||||||
|
update: updateAndDeleteAccess,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: 'email',
|
||||||
|
},
|
||||||
|
auth: true,
|
||||||
|
endpoints: [externalUsersLogin],
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'password',
|
||||||
|
hidden: true,
|
||||||
|
access: {
|
||||||
|
read: () => false, // Hide password field from read access
|
||||||
|
update: ({ req, id }) => {
|
||||||
|
const { user } = req
|
||||||
|
if (!user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === user.id) {
|
||||||
|
// Allow user to update their own password
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return isSuperAdmin(user)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
admin: {
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
name: 'roles',
|
||||||
|
type: 'select',
|
||||||
|
defaultValue: ['user'],
|
||||||
|
hasMany: true,
|
||||||
|
options: ['super-admin', 'user'],
|
||||||
|
access: {
|
||||||
|
update: ({ req }) => {
|
||||||
|
return isSuperAdmin(req.user)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'username',
|
||||||
|
type: 'text',
|
||||||
|
hooks: {
|
||||||
|
beforeValidate: [ensureUniqueUsername],
|
||||||
|
},
|
||||||
|
index: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...defaultTenantArrayField,
|
||||||
|
admin: {
|
||||||
|
...(defaultTenantArrayField?.admin || {}),
|
||||||
|
position: 'sidebar',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// The following hook sets a cookie based on the domain a user logs in from.
|
||||||
|
// It checks the domain and matches it to a tenant in the system, then sets
|
||||||
|
// a 'payload-tenant' cookie for that tenant.
|
||||||
|
|
||||||
|
hooks: {
|
||||||
|
afterLogin: [setCookieBasedOnDomain],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Users
|
||||||
328
src/payload-types.ts
Normal file
328
src/payload-types.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supported timezones in IANA format.
|
||||||
|
*
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "supportedTimezones".
|
||||||
|
*/
|
||||||
|
export type SupportedTimezones =
|
||||||
|
| 'Pacific/Midway'
|
||||||
|
| 'Pacific/Niue'
|
||||||
|
| 'Pacific/Honolulu'
|
||||||
|
| 'Pacific/Rarotonga'
|
||||||
|
| 'America/Anchorage'
|
||||||
|
| 'Pacific/Gambier'
|
||||||
|
| 'America/Los_Angeles'
|
||||||
|
| 'America/Tijuana'
|
||||||
|
| 'America/Denver'
|
||||||
|
| 'America/Phoenix'
|
||||||
|
| 'America/Chicago'
|
||||||
|
| 'America/Guatemala'
|
||||||
|
| 'America/New_York'
|
||||||
|
| 'America/Bogota'
|
||||||
|
| 'America/Caracas'
|
||||||
|
| 'America/Santiago'
|
||||||
|
| 'America/Buenos_Aires'
|
||||||
|
| 'America/Sao_Paulo'
|
||||||
|
| 'Atlantic/South_Georgia'
|
||||||
|
| 'Atlantic/Azores'
|
||||||
|
| 'Atlantic/Cape_Verde'
|
||||||
|
| 'Europe/London'
|
||||||
|
| 'Europe/Berlin'
|
||||||
|
| 'Africa/Lagos'
|
||||||
|
| 'Europe/Athens'
|
||||||
|
| 'Africa/Cairo'
|
||||||
|
| 'Europe/Moscow'
|
||||||
|
| 'Asia/Riyadh'
|
||||||
|
| 'Asia/Dubai'
|
||||||
|
| 'Asia/Baku'
|
||||||
|
| 'Asia/Karachi'
|
||||||
|
| 'Asia/Tashkent'
|
||||||
|
| 'Asia/Calcutta'
|
||||||
|
| 'Asia/Dhaka'
|
||||||
|
| 'Asia/Almaty'
|
||||||
|
| 'Asia/Jakarta'
|
||||||
|
| 'Asia/Bangkok'
|
||||||
|
| 'Asia/Shanghai'
|
||||||
|
| 'Asia/Singapore'
|
||||||
|
| 'Asia/Tokyo'
|
||||||
|
| 'Asia/Seoul'
|
||||||
|
| 'Australia/Sydney'
|
||||||
|
| 'Pacific/Guam'
|
||||||
|
| 'Pacific/Noumea'
|
||||||
|
| 'Pacific/Auckland'
|
||||||
|
| 'Pacific/Fiji';
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
auth: {
|
||||||
|
users: UserAuthOperations;
|
||||||
|
};
|
||||||
|
blocks: {};
|
||||||
|
collections: {
|
||||||
|
pages: Page;
|
||||||
|
users: User;
|
||||||
|
tenants: Tenant;
|
||||||
|
'payload-locked-documents': PayloadLockedDocument;
|
||||||
|
'payload-preferences': PayloadPreference;
|
||||||
|
'payload-migrations': PayloadMigration;
|
||||||
|
};
|
||||||
|
collectionsJoins: {};
|
||||||
|
collectionsSelect: {
|
||||||
|
pages: PagesSelect<false> | PagesSelect<true>;
|
||||||
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
|
tenants: TenantsSelect<false> | TenantsSelect<true>;
|
||||||
|
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||||
|
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
|
||||||
|
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||||
|
};
|
||||||
|
db: {
|
||||||
|
defaultIDType: number;
|
||||||
|
};
|
||||||
|
globals: {};
|
||||||
|
globalsSelect: {};
|
||||||
|
locale: null;
|
||||||
|
user: User & {
|
||||||
|
collection: 'users';
|
||||||
|
};
|
||||||
|
jobs: {
|
||||||
|
tasks: unknown;
|
||||||
|
workflows: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface UserAuthOperations {
|
||||||
|
forgotPassword: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
login: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
registerFirstUser: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
unlock: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "pages".
|
||||||
|
*/
|
||||||
|
export interface Page {
|
||||||
|
id: number;
|
||||||
|
tenant?: (number | null) | Tenant;
|
||||||
|
title?: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "tenants".
|
||||||
|
*/
|
||||||
|
export interface Tenant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Used for domain-based tenant handling
|
||||||
|
*/
|
||||||
|
domain?: string | null;
|
||||||
|
/**
|
||||||
|
* Used for url paths, example: /tenant-slug/page-slug
|
||||||
|
*/
|
||||||
|
slug: string;
|
||||||
|
/**
|
||||||
|
* If checked, logging in is not required to read. Useful for building public pages.
|
||||||
|
*/
|
||||||
|
allowPublicRead?: boolean | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users".
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
roles?: ('super-admin' | 'user')[] | null;
|
||||||
|
username?: string | null;
|
||||||
|
tenants?:
|
||||||
|
| {
|
||||||
|
tenant: number | Tenant;
|
||||||
|
roles: ('tenant-admin' | 'tenant-viewer')[];
|
||||||
|
id?: string | null;
|
||||||
|
}[]
|
||||||
|
| null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
email: string;
|
||||||
|
resetPasswordToken?: string | null;
|
||||||
|
resetPasswordExpiration?: string | null;
|
||||||
|
salt?: string | null;
|
||||||
|
hash?: string | null;
|
||||||
|
loginAttempts?: number | null;
|
||||||
|
lockUntil?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-locked-documents".
|
||||||
|
*/
|
||||||
|
export interface PayloadLockedDocument {
|
||||||
|
id: number;
|
||||||
|
document?:
|
||||||
|
| ({
|
||||||
|
relationTo: 'pages';
|
||||||
|
value: number | Page;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'users';
|
||||||
|
value: number | User;
|
||||||
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'tenants';
|
||||||
|
value: number | Tenant;
|
||||||
|
} | null);
|
||||||
|
globalSlug?: string | null;
|
||||||
|
user: {
|
||||||
|
relationTo: 'users';
|
||||||
|
value: number | User;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-preferences".
|
||||||
|
*/
|
||||||
|
export interface PayloadPreference {
|
||||||
|
id: number;
|
||||||
|
user: {
|
||||||
|
relationTo: 'users';
|
||||||
|
value: number | 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: number;
|
||||||
|
name?: string | null;
|
||||||
|
batch?: number | null;
|
||||||
|
updatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "pages_select".
|
||||||
|
*/
|
||||||
|
export interface PagesSelect<T extends boolean = true> {
|
||||||
|
tenant?: T;
|
||||||
|
title?: T;
|
||||||
|
slug?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "users_select".
|
||||||
|
*/
|
||||||
|
export interface UsersSelect<T extends boolean = true> {
|
||||||
|
roles?: T;
|
||||||
|
username?: T;
|
||||||
|
tenants?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
tenant?: T;
|
||||||
|
roles?: T;
|
||||||
|
id?: T;
|
||||||
|
};
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
email?: T;
|
||||||
|
resetPasswordToken?: T;
|
||||||
|
resetPasswordExpiration?: T;
|
||||||
|
salt?: T;
|
||||||
|
hash?: T;
|
||||||
|
loginAttempts?: T;
|
||||||
|
lockUntil?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "tenants_select".
|
||||||
|
*/
|
||||||
|
export interface TenantsSelect<T extends boolean = true> {
|
||||||
|
name?: T;
|
||||||
|
domain?: T;
|
||||||
|
slug?: T;
|
||||||
|
allowPublicRead?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-locked-documents_select".
|
||||||
|
*/
|
||||||
|
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||||
|
document?: T;
|
||||||
|
globalSlug?: T;
|
||||||
|
user?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-preferences_select".
|
||||||
|
*/
|
||||||
|
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||||
|
user?: T;
|
||||||
|
key?: T;
|
||||||
|
value?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "payload-migrations_select".
|
||||||
|
*/
|
||||||
|
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||||
|
name?: T;
|
||||||
|
batch?: T;
|
||||||
|
updatedAt?: T;
|
||||||
|
createdAt?: T;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
|
* via the `definition` "auth".
|
||||||
|
*/
|
||||||
|
export interface Auth {
|
||||||
|
[k: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
declare module 'payload' {
|
||||||
|
export interface GeneratedTypes extends Config {}
|
||||||
|
}
|
||||||
69
src/payload.config.ts
Normal file
69
src/payload.config.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||||
|
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||||
|
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'
|
||||||
|
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
|
||||||
|
import { isSuperAdmin } from './access/isSuperAdmin'
|
||||||
|
import type { Config } from './payload-types'
|
||||||
|
import { getUserTenantIDs } from './utilities/getUserTenantIDs'
|
||||||
|
import { seed } from './seed'
|
||||||
|
|
||||||
|
const filename = fileURLToPath(import.meta.url)
|
||||||
|
const dirname = path.dirname(filename)
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-exports
|
||||||
|
export default buildConfig({
|
||||||
|
admin: {
|
||||||
|
user: 'users',
|
||||||
|
},
|
||||||
|
collections: [Pages, Users, Tenants],
|
||||||
|
// db: mongooseAdapter({
|
||||||
|
// url: process.env.DATABASE_URI as string,
|
||||||
|
// }),
|
||||||
|
db: postgresAdapter({
|
||||||
|
pool: {
|
||||||
|
connectionString: process.env.POSTGRES_URL,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
onInit: async (args) => {
|
||||||
|
if (process.env.SEED_DB) {
|
||||||
|
await seed(args)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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'),
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
multiTenantPlugin<Config>({
|
||||||
|
collections: {
|
||||||
|
pages: {},
|
||||||
|
},
|
||||||
|
tenantField: {
|
||||||
|
access: {
|
||||||
|
read: () => true,
|
||||||
|
update: ({ req }) => {
|
||||||
|
if (isSuperAdmin(req.user)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return getUserTenantIDs(req.user).length > 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tenantsArrayField: {
|
||||||
|
includeDefaultField: false,
|
||||||
|
},
|
||||||
|
userHasAccessToAllTenants: (user) => isSuperAdmin(user),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
134
src/seed.ts
Normal file
134
src/seed.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Config } from 'payload'
|
||||||
|
|
||||||
|
export const seed: NonNullable<Config['onInit']> = async (payload): Promise<void> => {
|
||||||
|
const tenant1 = await payload.create({
|
||||||
|
collection: 'tenants',
|
||||||
|
data: {
|
||||||
|
name: 'Tenant 1',
|
||||||
|
slug: 'gold',
|
||||||
|
domain: 'gold.localhost',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tenant2 = await payload.create({
|
||||||
|
collection: 'tenants',
|
||||||
|
data: {
|
||||||
|
name: 'Tenant 2',
|
||||||
|
slug: 'silver',
|
||||||
|
domain: 'silver.localhost',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tenant3 = await payload.create({
|
||||||
|
collection: 'tenants',
|
||||||
|
data: {
|
||||||
|
name: 'Tenant 3',
|
||||||
|
slug: 'bronze',
|
||||||
|
domain: 'bronze.localhost',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'demo@payloadcms.com',
|
||||||
|
password: 'demo',
|
||||||
|
roles: ['super-admin'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'tenant1@payloadcms.com',
|
||||||
|
password: 'demo',
|
||||||
|
tenants: [
|
||||||
|
{
|
||||||
|
roles: ['tenant-admin'],
|
||||||
|
tenant: tenant1.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
username: 'tenant1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'tenant2@payloadcms.com',
|
||||||
|
password: 'demo',
|
||||||
|
tenants: [
|
||||||
|
{
|
||||||
|
roles: ['tenant-admin'],
|
||||||
|
tenant: tenant2.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
username: 'tenant2',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'tenant3@payloadcms.com',
|
||||||
|
password: 'demo',
|
||||||
|
tenants: [
|
||||||
|
{
|
||||||
|
roles: ['tenant-admin'],
|
||||||
|
tenant: tenant3.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
username: 'tenant3',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: 'multi-admin@payloadcms.com',
|
||||||
|
password: 'demo',
|
||||||
|
tenants: [
|
||||||
|
{
|
||||||
|
roles: ['tenant-admin'],
|
||||||
|
tenant: tenant1.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roles: ['tenant-admin'],
|
||||||
|
tenant: tenant2.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
roles: ['tenant-admin'],
|
||||||
|
tenant: tenant3.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
username: 'multi-admin',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'pages',
|
||||||
|
data: {
|
||||||
|
slug: 'home',
|
||||||
|
tenant: tenant1.id,
|
||||||
|
title: 'Page for Tenant 1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'pages',
|
||||||
|
data: {
|
||||||
|
slug: 'home',
|
||||||
|
tenant: tenant2.id,
|
||||||
|
title: 'Page for Tenant 2',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'pages',
|
||||||
|
data: {
|
||||||
|
slug: 'home',
|
||||||
|
tenant: tenant3.id,
|
||||||
|
title: 'Page for Tenant 3',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
10
src/utilities/extractID.ts
Normal file
10
src/utilities/extractID.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Config } from '@/payload-types'
|
||||||
|
import type { CollectionSlug } from 'payload'
|
||||||
|
|
||||||
|
export const extractID = <T extends Config['collections'][CollectionSlug]>(
|
||||||
|
objectOrID: T | T['id'],
|
||||||
|
): T['id'] => {
|
||||||
|
if (objectOrID && typeof objectOrID === 'object') return objectOrID.id
|
||||||
|
|
||||||
|
return objectOrID
|
||||||
|
}
|
||||||
9
src/utilities/getCollectionIDType.ts
Normal file
9
src/utilities/getCollectionIDType.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CollectionSlug, Payload } from 'payload'
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
collectionSlug: CollectionSlug
|
||||||
|
payload: Payload
|
||||||
|
}
|
||||||
|
export const getCollectionIDType = ({ collectionSlug, payload }: Args): 'number' | 'text' => {
|
||||||
|
return payload.collections[collectionSlug]?.customIDType ?? payload.db.defaultIDType
|
||||||
|
}
|
||||||
31
src/utilities/getUserTenantIDs.ts
Normal file
31
src/utilities/getUserTenantIDs.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Tenant, User } from '../payload-types'
|
||||||
|
import { extractID } from './extractID'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns array of all tenant IDs assigned to a user
|
||||||
|
*
|
||||||
|
* @param user - User object with tenants field
|
||||||
|
* @param role - Optional role to filter by
|
||||||
|
*/
|
||||||
|
export const getUserTenantIDs = (
|
||||||
|
user: null | User,
|
||||||
|
role?: NonNullable<User['tenants']>[number]['roles'][number],
|
||||||
|
): Tenant['id'][] => {
|
||||||
|
if (!user) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
user?.tenants?.reduce<Tenant['id'][]>((acc, { roles, tenant }) => {
|
||||||
|
if (role && !roles.includes(role)) {
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenant) {
|
||||||
|
acc.push(extractID(tenant))
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, []) || []
|
||||||
|
)
|
||||||
|
}
|
||||||
47
tsconfig.json
Normal file
47
tsconfig.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"lib": [
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable",
|
||||||
|
"ES2022"
|
||||||
|
],
|
||||||
|
"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": "ES2022",
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user