refact: init refact for mutitenent collections
This commit is contained in:
parent
b16a677e65
commit
9df88dbdda
141
package-lock.json
generated
141
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'
|
||||
|
||||
13
src/access/authenticatedOrPublished.ts
Normal file
13
src/access/authenticatedOrPublished.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import type { Access } from 'payload'
|
||||
|
||||
export const authenticatedOrPublished: Access = ({ req: { user } }) => {
|
||||
if (user) {
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
_status: {
|
||||
equals: 'published',
|
||||
},
|
||||
}
|
||||
}
|
||||
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'))
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
@ -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: [
|
||||
{
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
}
|
||||
24
src/collections/Users/access/create.ts
Normal file
24
src/collections/Users/access/create.ts
Normal 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
|
||||
}
|
||||
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
|
||||
}
|
||||
54
src/collections/Users/access/read.ts
Normal file
54
src/collections/Users/access/read.ts
Normal 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
|
||||
}
|
||||
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 { 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'),
|
||||
},
|
||||
}
|
||||
}
|
||||
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 { 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
|
||||
}
|
||||
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
|
||||
}
|
||||
111
src/collections/Users/index.ts
Normal file
111
src/collections/Users/index.ts
Normal 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
27
src/hooks/formatSlug.ts
Normal 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
|
||||
15
src/hooks/populatePublishedAt.ts
Normal file
15
src/hooks/populatePublishedAt.ts
Normal 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
|
||||
}
|
||||
11
src/hooks/revalidateRedirects.ts
Normal file
11
src/hooks/revalidateRedirects.ts
Normal 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
19
src/hooks/use-mobile.tsx
Normal 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
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
1
src/utilities/canUseDOM.ts
Normal file
1
src/utilities/canUseDOM.ts
Normal file
@ -0,0 +1 @@
|
||||
export default !!(typeof window !== 'undefined' && window.document && window.document.createElement)
|
||||
35
src/utilities/deepMerge.ts
Normal file
35
src/utilities/deepMerge.ts
Normal 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
|
||||
}
|
||||
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
|
||||
}
|
||||
24
src/utilities/formatAuthors.ts
Normal file
24
src/utilities/formatAuthors.ts
Normal 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]}`
|
||||
}
|
||||
20
src/utilities/formatDateTime.ts
Normal file
20
src/utilities/formatDateTime.ts
Normal 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}`
|
||||
}
|
||||
49
src/utilities/generateMeta.ts
Normal file
49
src/utilities/generateMeta.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
25
src/utilities/generatePreviewPath.ts
Normal file
25
src/utilities/generatePreviewPath.ts
Normal 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
|
||||
}
|
||||
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/getDocument.ts
Normal file
31
src/utilities/getDocument.ts
Normal 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}`],
|
||||
})
|
||||
26
src/utilities/getGlobals.ts
Normal file
26
src/utilities/getGlobals.ts
Normal 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}`],
|
||||
})
|
||||
43
src/utilities/getMeUser.ts
Normal file
43
src/utilities/getMeUser.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
26
src/utilities/getRedirects.ts
Normal file
26
src/utilities/getRedirects.ts
Normal 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
31
src/utilities/getURL.ts
Normal 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 || ''
|
||||
}
|
||||
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
|
||||
}, []) || []
|
||||
)
|
||||
}
|
||||
8
src/utilities/makeAcronym.ts
Normal file
8
src/utilities/makeAcronym.ts
Normal 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
|
||||
22
src/utilities/mergeOpenGraph.ts
Normal file
22
src/utilities/mergeOpenGraph.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
5
src/utilities/toKebabCase.ts
Normal file
5
src/utilities/toKebabCase.ts
Normal 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
12
src/utilities/ui.ts
Normal 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))
|
||||
}
|
||||
108
src/utilities/useClickableCard.ts
Normal file
108
src/utilities/useClickableCard.ts
Normal 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
|
||||
17
src/utilities/useDebounce.ts
Normal file
17
src/utilities/useDebounce.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user