feat: initial commit
This commit is contained in:
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
|
||||
}, []) || []
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user