From 9df88dbddac3f8b04a007af4f152eaaf9341b05c Mon Sep 17 00:00:00 2001 From: Yehoshua Sandler Date: Sat, 31 May 2025 19:50:50 -0500 Subject: [PATCH] refact: init refact for mutitenent collections --- package-lock.json | 141 ++++++++++++++++-- package.json | 2 + src/access/admin.ts | 7 - src/access/authenticatedOrPublished.ts | 13 ++ src/access/isSuperAdmin.ts | 10 ++ src/app/(payload)/admin/importMap.js | 10 +- src/collections/Authors/Authors.ts | 8 +- src/collections/Books/Books.ts | 7 +- src/collections/Books/Genre.ts | 7 +- src/collections/Checkouts/Checkouts.ts | 7 +- src/collections/Checkouts/HoldRequests.ts | 7 +- src/collections/Repositories/Repositories.ts | 7 +- src/collections/Tenants/access/byTenant.ts | 39 +++++ .../Tenants/access/updateAndDelete.ts | 19 +++ src/collections/Tenants/index.ts | 51 +++++++ src/collections/Users/access/create.ts | 24 +++ .../Users/access/isAccessingSelf.ts | 5 + src/collections/Users/access/read.ts | 54 +++++++ .../Users/access/updateAndDelete.ts | 31 ++++ .../Users/endpoints/externalUsersLogin.ts | 130 ++++++++++++++++ .../Users/hooks/ensureUniqueUsername.ts | 76 ++++++++++ .../Users/hooks/setCookieBasedOnDomain.ts | 39 +++++ src/collections/Users/index.ts | 111 ++++++++++++++ src/collections/{Users.ts => Users_OLD.ts} | 0 src/hooks/formatSlug.ts | 27 ++++ src/hooks/populatePublishedAt.ts | 15 ++ src/hooks/revalidateRedirects.ts | 11 ++ src/hooks/use-mobile.tsx | 19 +++ src/payload.config.ts | 34 ++++- src/utilities/canUseDOM.ts | 1 + src/utilities/deepMerge.ts | 35 +++++ src/utilities/extractID.ts | 10 ++ src/utilities/formatAuthors.ts | 24 +++ src/utilities/formatDateTime.ts | 20 +++ src/utilities/generateMeta.ts | 49 ++++++ src/utilities/generatePreviewPath.ts | 25 ++++ src/utilities/getCollectionIDType.ts | 9 ++ src/utilities/getDocument.ts | 31 ++++ src/utilities/getGlobals.ts | 26 ++++ src/utilities/getMeUser.ts | 43 ++++++ src/utilities/getRedirects.ts | 26 ++++ src/utilities/getURL.ts | 31 ++++ src/utilities/getUserTenantIds.ts | 31 ++++ src/utilities/makeAcronym.ts | 8 + src/utilities/mergeOpenGraph.ts | 22 +++ src/utilities/toKebabCase.ts | 5 + src/utilities/ui.ts | 12 ++ src/utilities/useClickableCard.ts | 108 ++++++++++++++ src/utilities/useDebounce.ts | 17 +++ 49 files changed, 1401 insertions(+), 43 deletions(-) delete mode 100644 src/access/admin.ts create mode 100644 src/access/authenticatedOrPublished.ts create mode 100644 src/access/isSuperAdmin.ts create mode 100644 src/collections/Tenants/access/byTenant.ts create mode 100644 src/collections/Tenants/access/updateAndDelete.ts create mode 100644 src/collections/Tenants/index.ts create mode 100644 src/collections/Users/access/create.ts create mode 100644 src/collections/Users/access/isAccessingSelf.ts create mode 100644 src/collections/Users/access/read.ts create mode 100644 src/collections/Users/access/updateAndDelete.ts create mode 100644 src/collections/Users/endpoints/externalUsersLogin.ts create mode 100644 src/collections/Users/hooks/ensureUniqueUsername.ts create mode 100644 src/collections/Users/hooks/setCookieBasedOnDomain.ts create mode 100644 src/collections/Users/index.ts rename src/collections/{Users.ts => Users_OLD.ts} (100%) create mode 100644 src/hooks/formatSlug.ts create mode 100644 src/hooks/populatePublishedAt.ts create mode 100644 src/hooks/revalidateRedirects.ts create mode 100644 src/hooks/use-mobile.tsx create mode 100644 src/utilities/canUseDOM.ts create mode 100644 src/utilities/deepMerge.ts create mode 100644 src/utilities/extractID.ts create mode 100644 src/utilities/formatAuthors.ts create mode 100644 src/utilities/formatDateTime.ts create mode 100644 src/utilities/generateMeta.ts create mode 100644 src/utilities/generatePreviewPath.ts create mode 100644 src/utilities/getCollectionIDType.ts create mode 100644 src/utilities/getDocument.ts create mode 100644 src/utilities/getGlobals.ts create mode 100644 src/utilities/getMeUser.ts create mode 100644 src/utilities/getRedirects.ts create mode 100644 src/utilities/getURL.ts create mode 100644 src/utilities/getUserTenantIds.ts create mode 100644 src/utilities/makeAcronym.ts create mode 100644 src/utilities/mergeOpenGraph.ts create mode 100644 src/utilities/toKebabCase.ts create mode 100644 src/utilities/ui.ts create mode 100644 src/utilities/useClickableCard.ts create mode 100644 src/utilities/useDebounce.ts diff --git a/package-lock.json b/package-lock.json index 8fb9e00..b787b85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index c42ce5d..5150422 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/access/admin.ts b/src/access/admin.ts deleted file mode 100644 index e6aafd0..0000000 --- a/src/access/admin.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { User } from '@/payload-types' -import type { AccessArgs } from 'payload' - -type isAdmin = (args: AccessArgs) => boolean - -export const admin: isAdmin = ({ req: { user } }) => user?.role === 'admin' - diff --git a/src/access/authenticatedOrPublished.ts b/src/access/authenticatedOrPublished.ts new file mode 100644 index 0000000..e49198f --- /dev/null +++ b/src/access/authenticatedOrPublished.ts @@ -0,0 +1,13 @@ +import type { Access } from 'payload' + +export const authenticatedOrPublished: Access = ({ req: { user } }) => { + if (user) { + return true + } + + return { + _status: { + equals: 'published', + }, + } +} diff --git a/src/access/isSuperAdmin.ts b/src/access/isSuperAdmin.ts new file mode 100644 index 0000000..f449243 --- /dev/null +++ b/src/access/isSuperAdmin.ts @@ -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')) +} diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index dfe0789..dce03fa 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -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 } diff --git a/src/collections/Authors/Authors.ts b/src/collections/Authors/Authors.ts index 24cc4d5..fdf90ac 100644 --- a/src/collections/Authors/Authors.ts +++ b/src/collections/Authors/Authors.ts @@ -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: [ { diff --git a/src/collections/Books/Books.ts b/src/collections/Books/Books.ts index 3cdaf65..a877694 100644 --- a/src/collections/Books/Books.ts +++ b/src/collections/Books/Books.ts @@ -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: [ { diff --git a/src/collections/Books/Genre.ts b/src/collections/Books/Genre.ts index 1ed3482..c0c386b 100644 --- a/src/collections/Books/Genre.ts +++ b/src/collections/Books/Genre.ts @@ -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: [ { diff --git a/src/collections/Checkouts/Checkouts.ts b/src/collections/Checkouts/Checkouts.ts index e4bb77a..b1d5323 100644 --- a/src/collections/Checkouts/Checkouts.ts +++ b/src/collections/Checkouts/Checkouts.ts @@ -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: [ { diff --git a/src/collections/Checkouts/HoldRequests.ts b/src/collections/Checkouts/HoldRequests.ts index 23c8d1d..6fce744 100644 --- a/src/collections/Checkouts/HoldRequests.ts +++ b/src/collections/Checkouts/HoldRequests.ts @@ -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: [ { diff --git a/src/collections/Repositories/Repositories.ts b/src/collections/Repositories/Repositories.ts index 5f5cb76..dfbad4b 100644 --- a/src/collections/Repositories/Repositories.ts +++ b/src/collections/Repositories/Repositories.ts @@ -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: [ { diff --git a/src/collections/Tenants/access/byTenant.ts b/src/collections/Tenants/access/byTenant.ts new file mode 100644 index 0000000..05fc99a --- /dev/null +++ b/src/collections/Tenants/access/byTenant.ts @@ -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) || [], + }, + } +} diff --git a/src/collections/Tenants/access/updateAndDelete.ts b/src/collections/Tenants/access/updateAndDelete.ts new file mode 100644 index 0000000..2a51e96 --- /dev/null +++ b/src/collections/Tenants/access/updateAndDelete.ts @@ -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'), + }, + } +} diff --git a/src/collections/Tenants/index.ts b/src/collections/Tenants/index.ts new file mode 100644 index 0000000..f1b614e --- /dev/null +++ b/src/collections/Tenants/index.ts @@ -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, + }, + ], +} diff --git a/src/collections/Users/access/create.ts b/src/collections/Users/access/create.ts new file mode 100644 index 0000000..ecdf214 --- /dev/null +++ b/src/collections/Users/access/create.ts @@ -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 = ({ 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 +} diff --git a/src/collections/Users/access/isAccessingSelf.ts b/src/collections/Users/access/isAccessingSelf.ts new file mode 100644 index 0000000..980a8b1 --- /dev/null +++ b/src/collections/Users/access/isAccessingSelf.ts @@ -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 +} diff --git a/src/collections/Users/access/read.ts b/src/collections/Users/access/read.ts new file mode 100644 index 0000000..203150c --- /dev/null +++ b/src/collections/Users/access/read.ts @@ -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 = ({ 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 +} diff --git a/src/collections/Users/access/updateAndDelete.ts b/src/collections/Users/access/updateAndDelete.ts new file mode 100644 index 0000000..018ec3f --- /dev/null +++ b/src/collections/Users/access/updateAndDelete.ts @@ -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'), + }, + } +} diff --git a/src/collections/Users/endpoints/externalUsersLogin.ts b/src/collections/Users/endpoints/externalUsersLogin.ts new file mode 100644 index 0000000..6b770f2 --- /dev/null +++ b/src/collections/Users/endpoints/externalUsersLogin.ts @@ -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', +} diff --git a/src/collections/Users/hooks/ensureUniqueUsername.ts b/src/collections/Users/hooks/ensureUniqueUsername.ts new file mode 100644 index 0000000..ab51e4d --- /dev/null +++ b/src/collections/Users/hooks/ensureUniqueUsername.ts @@ -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 +} diff --git a/src/collections/Users/hooks/setCookieBasedOnDomain.ts b/src/collections/Users/hooks/setCookieBasedOnDomain.ts new file mode 100644 index 0000000..fe01891 --- /dev/null +++ b/src/collections/Users/hooks/setCookieBasedOnDomain.ts @@ -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 +} diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts new file mode 100644 index 0000000..21728a4 --- /dev/null +++ b/src/collections/Users/index.ts @@ -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], + }, +} diff --git a/src/collections/Users.ts b/src/collections/Users_OLD.ts similarity index 100% rename from src/collections/Users.ts rename to src/collections/Users_OLD.ts diff --git a/src/hooks/formatSlug.ts b/src/hooks/formatSlug.ts new file mode 100644 index 0000000..43190f8 --- /dev/null +++ b/src/hooks/formatSlug.ts @@ -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 diff --git a/src/hooks/populatePublishedAt.ts b/src/hooks/populatePublishedAt.ts new file mode 100644 index 0000000..de974f7 --- /dev/null +++ b/src/hooks/populatePublishedAt.ts @@ -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 +} diff --git a/src/hooks/revalidateRedirects.ts b/src/hooks/revalidateRedirects.ts new file mode 100644 index 0000000..b5cc3ae --- /dev/null +++ b/src/hooks/revalidateRedirects.ts @@ -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 +} diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx new file mode 100644 index 0000000..2b0fe1d --- /dev/null +++ b/src/hooks/use-mobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +const MOBILE_BREAKPOINT = 768 + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(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 +} diff --git a/src/payload.config.ts b/src/payload.config.ts index c119a72..b8ff31b 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -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({ + 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: [ diff --git a/src/utilities/canUseDOM.ts b/src/utilities/canUseDOM.ts new file mode 100644 index 0000000..541b4de --- /dev/null +++ b/src/utilities/canUseDOM.ts @@ -0,0 +1 @@ +export default !!(typeof window !== 'undefined' && window.document && window.document.createElement) diff --git a/src/utilities/deepMerge.ts b/src/utilities/deepMerge.ts new file mode 100644 index 0000000..62cc18d --- /dev/null +++ b/src/utilities/deepMerge.ts @@ -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(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 +} diff --git a/src/utilities/extractID.ts b/src/utilities/extractID.ts new file mode 100644 index 0000000..fe97a16 --- /dev/null +++ b/src/utilities/extractID.ts @@ -0,0 +1,10 @@ +import { Config } from '@/payload-types' +import type { CollectionSlug } from 'payload' + +export const extractID = ( + objectOrID: T | T['id'], +): T['id'] => { + if (objectOrID && typeof objectOrID === 'object') return objectOrID.id + + return objectOrID +} diff --git a/src/utilities/formatAuthors.ts b/src/utilities/formatAuthors.ts new file mode 100644 index 0000000..d1e36c5 --- /dev/null +++ b/src/utilities/formatAuthors.ts @@ -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[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]}` +} diff --git a/src/utilities/formatDateTime.ts b/src/utilities/formatDateTime.ts new file mode 100644 index 0000000..ea68488 --- /dev/null +++ b/src/utilities/formatDateTime.ts @@ -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}` +} diff --git a/src/utilities/generateMeta.ts b/src/utilities/generateMeta.ts new file mode 100644 index 0000000..7395c87 --- /dev/null +++ b/src/utilities/generateMeta.ts @@ -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 | Partial | null +}): Promise => { + 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, + } +} diff --git a/src/utilities/generatePreviewPath.ts b/src/utilities/generatePreviewPath.ts new file mode 100644 index 0000000..72093dd --- /dev/null +++ b/src/utilities/generatePreviewPath.ts @@ -0,0 +1,25 @@ +import { PayloadRequest, CollectionSlug } from 'payload' + +const collectionPrefixMap: Partial> = { + 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 +} diff --git a/src/utilities/getCollectionIDType.ts b/src/utilities/getCollectionIDType.ts new file mode 100644 index 0000000..02ed72f --- /dev/null +++ b/src/utilities/getCollectionIDType.ts @@ -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 +} diff --git a/src/utilities/getDocument.ts b/src/utilities/getDocument.ts new file mode 100644 index 0000000..0dcb5ed --- /dev/null +++ b/src/utilities/getDocument.ts @@ -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}`], + }) diff --git a/src/utilities/getGlobals.ts b/src/utilities/getGlobals.ts new file mode 100644 index 0000000..d37a2aa --- /dev/null +++ b/src/utilities/getGlobals.ts @@ -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}`], + }) diff --git a/src/utilities/getMeUser.ts b/src/utilities/getMeUser.ts new file mode 100644 index 0000000..ad9e5c1 --- /dev/null +++ b/src/utilities/getMeUser.ts @@ -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, + } +} diff --git a/src/utilities/getRedirects.ts b/src/utilities/getRedirects.ts new file mode 100644 index 0000000..8eac7f2 --- /dev/null +++ b/src/utilities/getRedirects.ts @@ -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'], + }) diff --git a/src/utilities/getURL.ts b/src/utilities/getURL.ts new file mode 100644 index 0000000..c05c13e --- /dev/null +++ b/src/utilities/getURL.ts @@ -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 || '' +} diff --git a/src/utilities/getUserTenantIds.ts b/src/utilities/getUserTenantIds.ts new file mode 100644 index 0000000..a2e7b48 --- /dev/null +++ b/src/utilities/getUserTenantIds.ts @@ -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[number]['roles'][number], +): Tenant['id'][] => { + if (!user) { + return [] + } + + return ( + user?.tenants?.reduce((acc, { roles, tenant }) => { + if (role && !roles.includes(role)) { + return acc + } + + if (tenant) { + acc.push(extractID(tenant)) + } + + return acc + }, []) || [] + ) +} diff --git a/src/utilities/makeAcronym.ts b/src/utilities/makeAcronym.ts new file mode 100644 index 0000000..60ec190 --- /dev/null +++ b/src/utilities/makeAcronym.ts @@ -0,0 +1,8 @@ +const makeAcronym = (name: string, maxLength: number = 2) => { + return name + .split(' ') + .map((part) => part[0]) + .slice(0, maxLength) +} + +export default makeAcronym diff --git a/src/utilities/mergeOpenGraph.ts b/src/utilities/mergeOpenGraph.ts new file mode 100644 index 0000000..a331a2a --- /dev/null +++ b/src/utilities/mergeOpenGraph.ts @@ -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, + } +} diff --git a/src/utilities/toKebabCase.ts b/src/utilities/toKebabCase.ts new file mode 100644 index 0000000..228865f --- /dev/null +++ b/src/utilities/toKebabCase.ts @@ -0,0 +1,5 @@ +export const toKebabCase = (string: string): string => + string + ?.replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/\s+/g, '-') + .toLowerCase() diff --git a/src/utilities/ui.ts b/src/utilities/ui.ts new file mode 100644 index 0000000..fc08f6e --- /dev/null +++ b/src/utilities/ui.ts @@ -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)) +} diff --git a/src/utilities/useClickableCard.ts b/src/utilities/useClickableCard.ts new file mode 100644 index 0000000..7950ceb --- /dev/null +++ b/src/utilities/useClickableCard.ts @@ -0,0 +1,108 @@ +'use client' +import type { RefObject } from 'react' + +import { useRouter } from 'next/navigation' +import { useCallback, useEffect, useRef } from 'react' + +type UseClickableCardType = { + card: { + ref: RefObject + } + link: { + ref: RefObject + } +} + +interface Props { + external?: boolean + newTab?: boolean + scroll?: boolean +} + +function useClickableCard({ + external = false, + newTab = false, + scroll = true, +}: Props): UseClickableCardType { + const router = useRouter() + const card = useRef(null) + const link = useRef(null) + const timeDown = useRef(0) + const hasActiveParent = useRef(false) + const pressedButton = useRef(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 diff --git a/src/utilities/useDebounce.ts b/src/utilities/useDebounce.ts new file mode 100644 index 0000000..db97afd --- /dev/null +++ b/src/utilities/useDebounce.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react' + +export function useDebounce(value: T, delay = 200): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +}