Compare commits

..

1 Commits

Author SHA1 Message Date
9df88dbdda refact: init refact for mutitenent collections 2025-05-31 19:50:50 -05:00
49 changed files with 1401 additions and 43 deletions

141
package-lock.json generated
View File

@ -16,7 +16,9 @@
"@payloadcms/db-postgres": "3.31.0",
"@payloadcms/next": "3.31.0",
"@payloadcms/payload-cloud": "3.31.0",
"@payloadcms/plugin-multi-tenant": "^3.39.1",
"@payloadcms/richtext-lexical": "3.31.0",
"@payloadcms/ui": "^3.39.1",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.4",
@ -3952,6 +3954,55 @@
"payload": "3.31.0"
}
},
"node_modules/@payloadcms/next/node_modules/@payloadcms/ui": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@payloadcms/ui/-/ui-3.31.0.tgz",
"integrity": "sha512-SvRFqCmCo0PCOrwqFeNmL5EoJjGx7712l7pcvyMxpF0RmziZVAzqttnBizO3ha+7z65dJZFmyVHsawhO+iZk1Q==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "1.2.0",
"@dnd-kit/core": "6.0.8",
"@dnd-kit/sortable": "7.0.2",
"@faceless-ui/modal": "3.0.0-beta.2",
"@faceless-ui/scroll-info": "2.0.0",
"@faceless-ui/window-info": "3.0.1",
"@monaco-editor/react": "4.7.0",
"@payloadcms/translations": "3.31.0",
"bson-objectid": "2.0.4",
"date-fns": "4.1.0",
"dequal": "2.0.3",
"md5": "2.3.0",
"object-to-formdata": "4.5.1",
"qs-esm": "7.0.2",
"react-datepicker": "7.6.0",
"react-image-crop": "10.1.8",
"react-select": "5.9.0",
"scheduler": "0.25.0",
"sonner": "^1.7.2",
"ts-essentials": "10.0.3",
"use-context-selector": "2.0.0",
"uuid": "10.0.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"peerDependencies": {
"next": "^15.2.3",
"payload": "3.31.0",
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
}
},
"node_modules/@payloadcms/next/node_modules/sonner": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/@payloadcms/payload-cloud": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@payloadcms/payload-cloud/-/payload-cloud-3.31.0.tgz",
@ -3970,6 +4021,17 @@
"payload": "3.31.0"
}
},
"node_modules/@payloadcms/plugin-multi-tenant": {
"version": "3.39.1",
"resolved": "https://registry.npmjs.org/@payloadcms/plugin-multi-tenant/-/plugin-multi-tenant-3.39.1.tgz",
"integrity": "sha512-Uag8YRWoBwiUGNMdHCY1fJ7QNDPcw1nVGaHeSim1j+18NgiRTKsQD18xULei3UsrdYb9/mll7wZ5b3nPYauQGQ==",
"license": "MIT",
"peerDependencies": {
"@payloadcms/ui": "3.39.1",
"next": "^15.2.3",
"payload": "3.39.1"
}
},
"node_modules/@payloadcms/richtext-lexical": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@payloadcms/richtext-lexical/-/richtext-lexical-3.31.0.tgz",
@ -4015,16 +4077,7 @@
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
}
},
"node_modules/@payloadcms/translations": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@payloadcms/translations/-/translations-3.31.0.tgz",
"integrity": "sha512-vjbBuHJUZ04R7wkOR1+QhZRO1xG7bvkLgx6zoiKZZmvItqiPA5ZWsyrq3NFhviOH26dH2tOdnO+RLPuaElkWFg==",
"license": "MIT",
"dependencies": {
"date-fns": "4.1.0"
}
},
"node_modules/@payloadcms/ui": {
"node_modules/@payloadcms/richtext-lexical/node_modules/@payloadcms/ui": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@payloadcms/ui/-/ui-3.31.0.tgz",
"integrity": "sha512-SvRFqCmCo0PCOrwqFeNmL5EoJjGx7712l7pcvyMxpF0RmziZVAzqttnBizO3ha+7z65dJZFmyVHsawhO+iZk1Q==",
@ -4063,6 +4116,74 @@
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
}
},
"node_modules/@payloadcms/richtext-lexical/node_modules/sonner": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
"integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/@payloadcms/translations": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@payloadcms/translations/-/translations-3.31.0.tgz",
"integrity": "sha512-vjbBuHJUZ04R7wkOR1+QhZRO1xG7bvkLgx6zoiKZZmvItqiPA5ZWsyrq3NFhviOH26dH2tOdnO+RLPuaElkWFg==",
"license": "MIT",
"dependencies": {
"date-fns": "4.1.0"
}
},
"node_modules/@payloadcms/ui": {
"version": "3.39.1",
"resolved": "https://registry.npmjs.org/@payloadcms/ui/-/ui-3.39.1.tgz",
"integrity": "sha512-kjnLYSFgmqdyTPF4ecqqRUyHFb4hQalZ6Z/6qs6Sz9XYZHgY5hwN6MJIL6L/MDh0Tv7acaRUlTCQNWjzjEU79g==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "1.2.0",
"@dnd-kit/core": "6.0.8",
"@dnd-kit/sortable": "7.0.2",
"@dnd-kit/utilities": "3.2.2",
"@faceless-ui/modal": "3.0.0-beta.2",
"@faceless-ui/scroll-info": "2.0.0",
"@faceless-ui/window-info": "3.0.1",
"@monaco-editor/react": "4.7.0",
"@payloadcms/translations": "3.39.1",
"bson-objectid": "2.0.4",
"date-fns": "4.1.0",
"dequal": "2.0.3",
"md5": "2.3.0",
"object-to-formdata": "4.5.1",
"qs-esm": "7.0.2",
"react-datepicker": "7.6.0",
"react-image-crop": "10.1.8",
"react-select": "5.9.0",
"scheduler": "0.25.0",
"sonner": "^1.7.2",
"ts-essentials": "10.0.3",
"use-context-selector": "2.0.0",
"uuid": "10.0.0"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"peerDependencies": {
"next": "^15.2.3",
"payload": "3.39.1",
"react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020",
"react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020"
}
},
"node_modules/@payloadcms/ui/node_modules/@payloadcms/translations": {
"version": "3.39.1",
"resolved": "https://registry.npmjs.org/@payloadcms/translations/-/translations-3.39.1.tgz",
"integrity": "sha512-GwU6lwpi5hEijaE64dRNPIF1x8J2aN4loR+Z7Hqk2DP5UqtHTRmNx6LLTzanUfwvOiyR1nnFpCsHyAC57c8gtw==",
"license": "MIT",
"dependencies": {
"date-fns": "4.1.0"
}
},
"node_modules/@payloadcms/ui/node_modules/sonner": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",

View File

@ -23,7 +23,9 @@
"@payloadcms/db-postgres": "3.31.0",
"@payloadcms/next": "3.31.0",
"@payloadcms/payload-cloud": "3.31.0",
"@payloadcms/plugin-multi-tenant": "^3.39.1",
"@payloadcms/richtext-lexical": "3.31.0",
"@payloadcms/ui": "^3.39.1",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-label": "^2.1.4",

View File

@ -1,7 +0,0 @@
import { User } from '@/payload-types'
import type { AccessArgs } from 'payload'
type isAdmin = (args: AccessArgs<User>) => boolean
export const admin: isAdmin = ({ req: { user } }) => user?.role === 'admin'

View File

@ -0,0 +1,13 @@
import type { Access } from 'payload'
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
if (user) {
return true
}
return {
_status: {
equals: 'published',
},
}
}

View 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'))
}

View File

@ -1,3 +1,5 @@
import { WatchTenantCollection as WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
@ -20,8 +22,12 @@ import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/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#WatchTenantCollection": WatchTenantCollection_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
@ -43,5 +49,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/plugin-multi-tenant/client#TenantSelector": TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
}

View File

@ -1,4 +1,4 @@
import { admin } from '@/access/admin'
//import { admin } from '@/access/admin'
import { authenticated } from '@/access/authenticated'
import type { CollectionConfig } from 'payload'
@ -12,9 +12,9 @@ export const Authors: CollectionConfig = {
},
access: {
read: () => true,
update: admin,
create: authenticated,
delete: admin,
// update: admin,
// create: authenticated,
// delete: admin,
},
fields: [
{

View File

@ -1,4 +1,3 @@
import { admin } from "@/access/admin";
import { authenticated } from "@/access/authenticated";
import { CollectionConfig } from "payload";
@ -9,9 +8,9 @@ export const Books: CollectionConfig = {
},
access: {
read: () => true,
update: admin,
create: authenticated,
delete: admin,
// update: admin,
// create: authenticated,
// delete: admin,
},
fields: [
{

View File

@ -1,4 +1,3 @@
import { admin } from "@/access/admin";
import { CollectionConfig } from "payload";
export const Genre: CollectionConfig = {
@ -8,9 +7,9 @@ export const Genre: CollectionConfig = {
},
access: {
read: () => true,
update: admin,
create: () => true,
delete: admin,
// update: admin,
// create: () => true,
// delete: admin,
},
fields: [
{

View File

@ -1,4 +1,3 @@
import { admin } from "@/access/admin";
import { authenticated } from "@/access/authenticated";
import { CollectionConfig } from "payload";
@ -6,9 +5,9 @@ const Checkouts: CollectionConfig = {
slug: 'checkouts',
access: {
read: () => true,
update: admin,
create: authenticated,
delete: admin,
// update: admin,
// create: authenticated,
// delete: admin,
},
fields: [
{

View File

@ -1,4 +1,3 @@
import { admin } from "@/access/admin";
import { authenticated } from "@/access/authenticated";
import { CollectionConfig } from "payload";
@ -6,9 +5,9 @@ const HoldRequests: CollectionConfig = {
slug: 'holdRequests',
access: {
read: () => true,
update: admin,
create: authenticated,
delete: admin,
// update: admin,
// create: authenticated,
// delete: admin,
},
fields: [
{

View File

@ -1,4 +1,3 @@
import { admin } from "@/access/admin";
import { authenticated } from "@/access/authenticated";
import { CollectionConfig } from "payload";
@ -9,9 +8,9 @@ export const Repositories: CollectionConfig = {
},
access: {
read: () => true,
update: admin,
create: authenticated,
delete: admin,
// update: admin,
// create: authenticated,
// delete: admin,
},
fields: [
{

View 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) || [],
},
}
}

View 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'),
},
}
}

View 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,
},
],
}

View File

@ -0,0 +1,24 @@
import type { Access } from 'payload'
import type { 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
}
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
if (adminTenantAccessIDs.length) {
return true
}
return false
}

View 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
}

View File

@ -0,0 +1,54 @@
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 { isAccessingSelf } from './isAccessingSelf'
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
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)
if (superAdmin) return true;
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,
},
}
}
}
return {
or: [
{
id: {
equals: req.user.id,
},
},
{
'tenants.tenant': {
in: adminTenantAccessIDs,
},
},
],
} as Where
}

View File

@ -0,0 +1,31 @@
import type { Access } from 'payload'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import { isAccessingSelf } from './isAccessingSelf'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
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'),
},
}
}

View 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',
}

View File

@ -0,0 +1,76 @@
import type { FieldHook, Where } from 'payload'
import { ValidationError } from 'payload'
import { extractID } from '@/utilities/extractID'
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
import { getUserTenantIDs } from '@/utilities/getUserTenantIds'
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
}

View 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
}

View File

@ -0,0 +1,111 @@
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'
export enum UserAccessLevel {
GUEST = 0,
CLIENT,
TRAINER,
TENANT_ADMIN,
SUPER_ADMIN,
FULL_ACCESS,
}
const defaultTenantArrayField = tenantsArrayField({
tenantsArrayFieldName: 'tenants',
tenantsArrayTenantFieldName: 'tenant',
tenantsCollectionSlug: 'tenants',
arrayFieldAccess: {},
tenantFieldAccess: {},
rowFields: [
{
name: 'roles',
type: 'select',
defaultValue: ['tenant-client'],
hasMany: true,
options: ['tenant-admin', 'tenant-client', 'tenant-owner'],
required: true,
},
],
})
export const Users: CollectionConfig = {
slug: 'users',
access: {
create: createAccess,
delete: updateAndDeleteAccess,
read: readAccess,
update: updateAndDeleteAccess,
},
admin: {
defaultColumns: ['name', 'email'],
useAsTitle: 'username',
group: 'User Data',
},
auth: true,
endpoints: [externalUsersLogin],
fields: [
{
admin: {
position: 'sidebar',
},
name: 'roles',
type: 'select',
defaultValue: ['guest'],
hasMany: true,
options: ['full-access', 'super-admin', 'user', 'guest'],
access: {
update: ({ req }) => {
return isSuperAdmin(req.user)
},
},
},
{
name: 'username',
type: 'text',
hooks: {
beforeValidate: [ensureUniqueUsername],
},
index: true,
},
{
name: 'firstName',
type: 'text',
},
{
name: 'lastName',
type: 'text',
},
{
name: 'repositories',
type: 'join',
collection: 'repositories',
on: 'owner',
},
{
name: 'profilePicture',
type: 'relationship',
relationTo: 'media',
},
{
...defaultTenantArrayField,
admin: {
...(defaultTenantArrayField?.admin || {}),
position: 'sidebar',
},
},
],
timestamps: true,
hooks: {
afterLogin: [setCookieBasedOnDomain],
},
}

27
src/hooks/formatSlug.ts Normal file
View File

@ -0,0 +1,27 @@
import type { FieldHook } from 'payload'
const format = (val: string): string =>
val
.replace(/ /g, '-')
.replace(/[^\w-]+/g, '')
.toLowerCase()
const formatSlug =
(fallback: string): FieldHook =>
({ data, operation, originalDoc, value }) => {
if (typeof value === 'string') {
return format(value)
}
if (operation === 'create') {
const fallbackData = data?.[fallback] || originalDoc?.[fallback]
if (fallbackData && typeof fallbackData === 'string') {
return format(fallbackData)
}
}
return value
}
export default formatSlug

View File

@ -0,0 +1,15 @@
import type { CollectionBeforeChangeHook } from 'payload'
export const populatePublishedAt: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation === 'create' || operation === 'update') {
if (req.data && !req.data.publishedAt) {
const now = new Date()
return {
...data,
publishedAt: now,
}
}
}
return data
}

View File

@ -0,0 +1,11 @@
import type { CollectionAfterChangeHook } from 'payload'
import { revalidateTag } from 'next/cache'
export const revalidateRedirects: CollectionAfterChangeHook = ({ doc, req: { payload } }) => {
payload.logger.info(`Revalidating redirects`)
revalidateTag('redirects')
return doc
}

19
src/hooks/use-mobile.tsx Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@ -1,6 +1,7 @@
// storage-adapter-import-placeholder
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import { nodemailerAdapter } from '@payloadcms/email-nodemailer'
import path from 'path'
import { buildConfig } from 'payload'
@ -18,8 +19,12 @@ import { Header } from './globals/header/config'
import { Pages } from './collections/Pages/Pages'
import HoldRequests from './collections/Checkouts/HoldRequests'
import Checkouts from './collections/Checkouts/Checkouts'
import { isSuperAdmin } from '@/access/isSuperAdmin'
import './envConfig.ts'
import { Config, User } from './payload-types'
import { getUserTenantIDs } from './utilities/getUserTenantIds'
import { Tenants } from './collections/Tenants'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@ -40,7 +45,7 @@ export default buildConfig({
},
},
globals: [Header],
collections: [Users, Media, Books, Authors, Repositories, Copies, HoldRequests, Checkouts, Genre, Pages],
collections: [Tenants, Users, Media, Books, Authors, Repositories, Copies, HoldRequests, Checkouts, Genre, Pages],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
@ -67,6 +72,33 @@ export default buildConfig({
plugins: [
//payloadCloudPlugin(),
// storage-adapter-placeholder
multiTenantPlugin<Config>({
debug: true,
enabled: true,
collections: {
pages: {},
books: {},
copies: {},
repositories: {},
checkouts: {},
holdRequests: {},
},
tenantsArrayField: {
includeDefaultField: false,
},
userHasAccessToAllTenants: (user: User) => isSuperAdmin(user),
tenantField: {
access: {
read: () => true,
update: ({ req }) => {
if (isSuperAdmin(req.user)) {
return true
}
return getUserTenantIDs(req.user).length > 0
},
},
},
}),
],
hooks: {
afterError: [

View File

@ -0,0 +1 @@
export default !!(typeof window !== 'undefined' && window.document && window.document.createElement)

View File

@ -0,0 +1,35 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: unknown): item is object {
return typeof item === 'object' && !Array.isArray(item)
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] })
} else {
output[key] = deepMerge(target[key], source[key])
}
} else {
Object.assign(output, { [key]: source[key] })
}
})
}
return output
}

View 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
}

View File

@ -0,0 +1,24 @@
import { Post } from '@/payload-types'
/**
* Formats an array of populatedAuthors from Posts into a prettified string.
* @param authors - The populatedAuthors array from a Post.
* @returns A prettified string of authors.
* @example
*
* [Author1, Author2] becomes 'Author1 and Author2'
* [Author1, Author2, Author3] becomes 'Author1, Author2, and Author3'
*
*/
export const formatAuthors = (
authors: NonNullable<NonNullable<Post['populatedAuthors']>[number]>[],
) => {
// Ensure we don't have any authors without a name
const authorNames = authors.map((author) => author.name).filter(Boolean)
if (authorNames.length === 0) return ''
if (authorNames.length === 1) return authorNames[0]
if (authorNames.length === 2) return `${authorNames[0]} and ${authorNames[1]}`
return `${authorNames.slice(0, -1).join(', ')} and ${authorNames[authorNames.length - 1]}`
}

View File

@ -0,0 +1,20 @@
export const formatDateTime = (timestamp: string): string => {
const now = new Date()
let date = now
if (timestamp) date = new Date(timestamp)
const months = date.getMonth()
const days = date.getDate()
// const hours = date.getHours();
// const minutes = date.getMinutes();
// const seconds = date.getSeconds();
const MM = months + 1 < 10 ? `0${months + 1}` : months + 1
const DD = days < 10 ? `0${days}` : days
const YYYY = date.getFullYear()
// const AMPM = hours < 12 ? 'AM' : 'PM';
// const HH = hours > 12 ? hours - 12 : hours;
// const MinMin = (minutes < 10) ? `0${minutes}` : minutes;
// const SS = (seconds < 10) ? `0${seconds}` : seconds;
return `${MM}/${DD}/${YYYY}`
}

View File

@ -0,0 +1,49 @@
import type { Metadata } from 'next'
import type { Media, Page, Post, Config } from '../payload-types'
import { mergeOpenGraph } from './mergeOpenGraph'
import { getServerSideURL } from './getURL'
const getImageURL = (image?: Media | Config['db']['defaultIDType'] | null) => {
const serverUrl = getServerSideURL()
let url = serverUrl + '/website-template-OG.webp'
if (image && typeof image === 'object' && 'url' in image) {
const ogUrl = image.sizes?.og?.url
url = ogUrl ? serverUrl + ogUrl : serverUrl + image.url
}
return url
}
export const generateMeta = async (args: {
doc: Partial<Page> | Partial<Post> | null
}): Promise<Metadata> => {
const { doc } = args
const ogImage = getImageURL(doc?.meta?.image)
const title = doc?.meta?.title
? doc?.meta?.title + ' | Payload Website Template'
: 'Payload Website Template'
return {
description: doc?.meta?.description,
openGraph: mergeOpenGraph({
description: doc?.meta?.description || '',
images: ogImage
? [
{
url: ogImage,
},
]
: undefined,
title,
url: Array.isArray(doc?.slug) ? doc?.slug.join('/') : '/',
}),
title,
}
}

View File

@ -0,0 +1,25 @@
import { PayloadRequest, CollectionSlug } from 'payload'
const collectionPrefixMap: Partial<Record<CollectionSlug, string>> = {
posts: '/posts',
pages: '',
}
type Props = {
collection: keyof typeof collectionPrefixMap
slug: string
req: PayloadRequest
}
export const generatePreviewPath = ({ collection, slug }: Props) => {
const encodedParams = new URLSearchParams({
slug,
collection,
path: `${collectionPrefixMap[collection]}/${slug}`,
previewSecret: process.env.PREVIEW_SECRET || '',
})
const url = `/next/preview?${encodedParams.toString()}`
return url
}

View 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
}

View File

@ -0,0 +1,31 @@
import type { Config } from 'src/payload-types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { unstable_cache } from 'next/cache'
type Collection = keyof Config['collections']
async function getDocument(collection: Collection, slug: string, depth = 0) {
const payload = await getPayload({ config: configPromise })
const page = await payload.find({
collection,
depth,
where: {
slug: {
equals: slug,
},
},
})
return page.docs[0]
}
/**
* Returns a unstable_cache function mapped with the cache tag for the slug
*/
export const getCachedDocument = (collection: Collection, slug: string) =>
unstable_cache(async () => getDocument(collection, slug), [collection, slug], {
tags: [`${collection}_${slug}`],
})

View File

@ -0,0 +1,26 @@
import type { Config } from 'src/payload-types'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { unstable_cache } from 'next/cache'
type Global = keyof Config['globals']
async function getGlobal(slug: Global, depth = 0) {
const payload = await getPayload({ config: configPromise })
const global = await payload.findGlobal({
slug,
depth,
})
return global
}
/**
* Returns a unstable_cache function mapped with the cache tag for the slug
*/
export const getCachedGlobal = (slug: Global, depth = 0) =>
unstable_cache(async () => getGlobal(slug, depth), [slug], {
tags: [`global_${slug}`],
})

View File

@ -0,0 +1,43 @@
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import type { User } from '../payload-types'
import { getClientSideURL } from './getURL'
export const getMeUser = async (args?: {
nullUserRedirect?: string
validUserRedirect?: string
}): Promise<{
token: string
user: User
}> => {
const { nullUserRedirect, validUserRedirect } = args || {}
const cookieStore = await cookies()
const token = cookieStore.get('payload-token')?.value
const meUserReq = await fetch(`${getClientSideURL()}/api/users/me`, {
headers: {
Authorization: `JWT ${token}`,
},
})
const {
user,
}: {
user: User
} = await meUserReq.json()
if (validUserRedirect && meUserReq.ok && user) {
redirect(validUserRedirect)
}
if (nullUserRedirect && (!meUserReq.ok || !user)) {
redirect(nullUserRedirect)
}
// Token will exist here because if it doesn't the user will be redirected
return {
token: token!,
user,
}
}

View File

@ -0,0 +1,26 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { unstable_cache } from 'next/cache'
export async function getRedirects(depth = 1) {
const payload = await getPayload({ config: configPromise })
const { docs: redirects } = await payload.find({
collection: 'redirects',
depth,
limit: 0,
pagination: false,
})
return redirects
}
/**
* Returns a unstable_cache function mapped with the cache tag for 'redirects'.
*
* Cache all redirects together to avoid multiple fetches.
*/
export const getCachedRedirects = () =>
unstable_cache(async () => getRedirects(), ['redirects'], {
tags: ['redirects'],
})

31
src/utilities/getURL.ts Normal file
View File

@ -0,0 +1,31 @@
import canUseDOM from './canUseDOM'
export const getServerSideURL = () => {
let url = process.env.NEXT_PUBLIC_SERVER_URL
if (!url && process.env.VERCEL_PROJECT_PRODUCTION_URL) {
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
}
if (!url) {
url = 'http://localhost:3000'
}
return url
}
export const getClientSideURL = () => {
if (canUseDOM) {
const protocol = window.location.protocol
const domain = window.location.hostname
const port = window.location.port
return `${protocol}//${domain}${port ? `:${port}` : ''}`
}
if (process.env.VERCEL_PROJECT_PRODUCTION_URL) {
return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
}
return process.env.NEXT_PUBLIC_SERVER_URL || ''
}

View 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
}, []) || []
)
}

View File

@ -0,0 +1,8 @@
const makeAcronym = (name: string, maxLength: number = 2) => {
return name
.split(' ')
.map((part) => part[0])
.slice(0, maxLength)
}
export default makeAcronym

View File

@ -0,0 +1,22 @@
import type { Metadata } from 'next'
import { getServerSideURL } from './getURL'
const defaultOpenGraph: Metadata['openGraph'] = {
type: 'website',
description: 'An open-source website built with Payload and Next.js.',
images: [
{
url: `${getServerSideURL()}/website-template-OG.webp`,
},
],
siteName: 'Payload Website Template',
title: 'Payload Website Template',
}
export const mergeOpenGraph = (og?: Metadata['openGraph']): Metadata['openGraph'] => {
return {
...defaultOpenGraph,
...og,
images: og?.images ? og.images : defaultOpenGraph.images,
}
}

View File

@ -0,0 +1,5 @@
export const toKebabCase = (string: string): string =>
string
?.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/\s+/g, '-')
.toLowerCase()

12
src/utilities/ui.ts Normal file
View File

@ -0,0 +1,12 @@
/**
* Utility functions for UI components automatically added by ShadCN and used in a few of our frontend components and blocks.
*
* Other functions may be exported from here in the future or by installing other shadcn components.
*/
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,108 @@
'use client'
import type { RefObject } from 'react'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef } from 'react'
type UseClickableCardType<T extends HTMLElement> = {
card: {
ref: RefObject<T | null>
}
link: {
ref: RefObject<HTMLAnchorElement | null>
}
}
interface Props {
external?: boolean
newTab?: boolean
scroll?: boolean
}
function useClickableCard<T extends HTMLElement>({
external = false,
newTab = false,
scroll = true,
}: Props): UseClickableCardType<T> {
const router = useRouter()
const card = useRef<T>(null)
const link = useRef<HTMLAnchorElement>(null)
const timeDown = useRef<number>(0)
const hasActiveParent = useRef<boolean>(false)
const pressedButton = useRef<number>(0)
const handleMouseDown = useCallback(
(e: MouseEvent) => {
if (e.target) {
const target = e.target as Element
const timeNow = +new Date()
const parent = target?.closest('a')
pressedButton.current = e.button
if (!parent) {
hasActiveParent.current = false
timeDown.current = timeNow
} else {
hasActiveParent.current = true
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[router, card, link, timeDown],
)
const handleMouseUp = useCallback(
(e: MouseEvent) => {
if (link.current?.href) {
const timeNow = +new Date()
const difference = timeNow - timeDown.current
if (link.current?.href && difference <= 250) {
if (!hasActiveParent.current && pressedButton.current === 0 && !e.ctrlKey) {
if (external) {
const target = newTab ? '_blank' : '_self'
window.open(link.current.href, target)
} else {
router.push(link.current.href, { scroll })
}
}
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[router, card, link, timeDown],
)
useEffect(() => {
const cardNode = card.current
const abortController = new AbortController()
if (cardNode) {
cardNode.addEventListener('mousedown', handleMouseDown, {
signal: abortController.signal,
})
cardNode.addEventListener('mouseup', handleMouseUp, {
signal: abortController.signal,
})
}
return () => {
abortController.abort()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [card, link, router])
return {
card: {
ref: card,
},
link: {
ref: link,
},
}
}
export default useClickableCard

View File

@ -0,0 +1,17 @@
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay = 200): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => {
clearTimeout(handler)
}
}, [value, delay])
return debouncedValue
}