diff --git a/netlify/functions/tina.ts b/netlify/functions/tina.ts index 461f7a64..11b2137e 100644 --- a/netlify/functions/tina.ts +++ b/netlify/functions/tina.ts @@ -10,6 +10,8 @@ import authConfig from '@root/auth.config'; import { Session } from '@auth/core/types'; import { Auth } from '@auth/core'; import { AuthJsBackendAuthProvider, TinaAuthJSOptions } from 'tinacms-authjs'; +import { Clerk, verifyToken } from '@clerk/backend'; +import { enforceEditorRules } from '../tina/clerk-rbac'; dotenv.config(); @@ -23,6 +25,7 @@ app.use(cookieParser()); const isLocal = process.env.TINA_PUBLIC_IS_LOCAL === 'true'; const useSSO = process.env.TINA_PUBLIC_AUTH_USE_KEYCLOAK === 'true'; +const useClerk = process.env.TINA_PUBLIC_AUTH_USE_CLERK === 'true'; async function getSession(req: Request, options = authConfig): Promise { // @ts-ignore @@ -43,6 +46,45 @@ async function getSession(req: Request, options = authConfig): Promise(); +const CLERK_CACHE_TTL = 60_000; // 60 seconds + +const ClerkRBACAuth = (secretKey: string) => ({ + isAuthorized: async (req: any, _res: any) => { + const token = req.headers.authorization?.replace('Bearer ', ''); + if (!token) return { isAuthorized: false as const, errorCode: 401, errorMessage: 'No token' }; + + // Check cache + const cached = clerkTokenCache.get(token); + if (cached && cached.expires > Date.now()) { + Object.assign(req, cached.result); + return { isAuthorized: true as const }; + } + + try { + const clerk = Clerk({ secretKey }); + const session = await verifyToken(token); + const user = await clerk.users.getUser(session.sub); + const role = (user.publicMetadata as any)?.role || 'editor'; + + const reqData = { + __clerkRole: role, + __clerkUserId: session.sub, + __clerkUserName: [user.firstName, user.lastName].filter(Boolean).join(' ') + || user.emailAddresses[0]?.emailAddress, + }; + + Object.assign(req, reqData); + clerkTokenCache.set(token, { result: reqData, expires: Date.now() + CLERK_CACHE_TTL }); + + return { isAuthorized: true as const }; + } catch (err) { + return { isAuthorized: false as const, errorCode: 401, errorMessage: 'Invalid token' }; + } + } +}); + const CustomBackendAuth = () => { return { isAuthorized: async (req, res) : Promise<{ isAuthorized: true } | { isAuthorized: false, errorCode: number, errorMessage: string }> => { @@ -64,15 +106,17 @@ const CustomBackendAuth = () => { const authProvider = isLocal ? LocalBackendAuthProvider() - : useSSO - ? CustomBackendAuth() - : AuthJsBackendAuthProvider({ - authOptions: TinaAuthJSOptions({ - databaseClient, - secret: process.env.NEXTAUTH_SECRET!, - debug: true - }) - }) + : useClerk + ? ClerkRBACAuth(process.env.CLERK_SECRET_KEY!) + : useSSO + ? CustomBackendAuth() + : AuthJsBackendAuthProvider({ + authOptions: TinaAuthJSOptions({ + databaseClient, + secret: process.env.NEXTAUTH_SECRET!, + debug: true + }) + }) const tinaBackend = TinaNodeBackend({ authProvider, @@ -96,7 +140,17 @@ const mediaHandler = createMediaHandler({ } }); -app.post('/api/tina/*', async (req, res) => { +app.post('/api/tina/*', async (req: any, res: any) => { + if (useClerk) { + const authResult = await authProvider.isAuthorized(req, res); + if (!authResult.isAuthorized) { + return res.status(authResult.errorCode).json({ error: authResult.errorMessage }); + } + if (req.__clerkRole === 'editor' && req.body?.query) { + const rejection = await enforceEditorRules(req.body, req.__clerkUserId, databaseClient); + if (rejection) return res.status(403).json({ error: rejection }); + } + } tinaBackend(req, res); }); diff --git a/package-lock.json b/package-lock.json index 5619764d..1f9363d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "@astrojs/sitemap": "^3.4.1", "@astrojs/ts-plugin": "^1.10.4", "@auth/core": "^0.37.4", + "@clerk/backend": "^0.38.15", + "@clerk/clerk-js": "^4.73.14", "@cu-mkp/editioncrafter": "^1.3.1-beta.12", "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.1.1", @@ -63,6 +65,7 @@ "tailwindcss": "^4.1.4", "tinacms": "^3.6.1", "tinacms-authjs": "^20.0.1", + "tinacms-clerk": "^20.0.1", "typescript": "^5.9.3", "typesense-instantsearch-adapter": "^2.7.1", "underscore": "^1.13.6", @@ -2728,6 +2731,246 @@ "node": ">=18" } }, + "node_modules/@clerk/backend": { + "version": "0.38.15", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-0.38.15.tgz", + "integrity": "sha512-zmd0jPyb1iALlmyzyRbgujQXrGqw8sf+VpFjm5GkndpBeq5+9+oH7QgMaFEmWi9oxvTd2sZ+EN+QT4+OXPUnGA==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "1.4.2", + "@clerk/types": "3.65.5", + "@peculiar/webcrypto": "1.4.1", + "@types/node": "16.18.6", + "cookie": "0.5.0", + "deepmerge": "4.2.2", + "node-fetch-native": "1.0.1", + "snakecase-keys": "5.4.4", + "tslib": "2.4.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@clerk/backend/node_modules/@types/node": { + "version": "16.18.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.6.tgz", + "integrity": "sha512-vmYJF0REqDyyU0gviezF/KHq/fYaUbFhkcNbQCuPGFQj6VTbXuHZoxs/Y7mutWe73C8AC6l9fFu8mSYiBAqkGA==", + "license": "MIT" + }, + "node_modules/@clerk/backend/node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@clerk/backend/node_modules/deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@clerk/backend/node_modules/node-fetch-native": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.0.1.tgz", + "integrity": "sha512-VzW+TAk2wE4X9maiKMlT+GsPU4OMmR1U9CrHSmd3DFLn2IcZ9VJ6M6BBugGfYUnPCLSYxXdZy17M0BEJyhUTwg==", + "license": "MIT" + }, + "node_modules/@clerk/clerk-js": { + "version": "4.73.14", + "resolved": "https://registry.npmjs.org/@clerk/clerk-js/-/clerk-js-4.73.14.tgz", + "integrity": "sha512-X2E7jP1aBm1Qa7+Ykgbd46+dYvwGKCw9/WJkfVQm3gYc0b+u2v22trSWT58ngM3Gt4cLA/frhnpjhxAO+ypUjg==", + "license": "MIT", + "dependencies": { + "@clerk/localizations": "1.28.10", + "@clerk/shared": "1.4.2", + "@clerk/types": "3.65.5", + "@emotion/cache": "11.11.0", + "@emotion/react": "11.11.1", + "@floating-ui/react": "0.25.4", + "@zxcvbn-ts/core": "3.0.4", + "@zxcvbn-ts/language-common": "3.0.4", + "browser-tabs-lock": "1.2.15", + "copy-to-clipboard": "3.3.3", + "core-js": "3.26.1", + "dequal": "2.0.3", + "qrcode.react": "3.1.0", + "qs": "6.11.0", + "regenerator-runtime": "0.13.11" + }, + "peerDependencies": { + "react": ">=18" + } + }, + "node_modules/@clerk/clerk-js/node_modules/@emotion/cache": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" + } + }, + "node_modules/@clerk/clerk-js/node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==", + "license": "MIT" + }, + "node_modules/@clerk/clerk-js/node_modules/@emotion/react": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.1.tgz", + "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@clerk/clerk-js/node_modules/@emotion/weak-memoize": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==", + "license": "MIT" + }, + "node_modules/@clerk/clerk-js/node_modules/@floating-ui/react": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.25.4.tgz", + "integrity": "sha512-lWRQ/UiTvSIBxohn0/2HFHEmnmOVRjl7j6XcRJuLH0ls6f/9AyHMWVzkAJFuwx0n9gaEeCmg9VccCSCJzbEJig==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.2", + "@floating-ui/utils": "^0.1.1", + "tabbable": "^6.0.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@clerk/clerk-js/node_modules/@floating-ui/utils": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", + "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==", + "license": "MIT" + }, + "node_modules/@clerk/clerk-js/node_modules/core-js": { + "version": "3.26.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.1.tgz", + "integrity": "sha512-21491RRQVzUn0GGM9Z1Jrpr6PNPxPi+Za8OM9q4tksTSnlbXXGKK1nXNg/QvwFYettXvSX6zWKCtHHfjN4puyA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/@clerk/clerk-js/node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@clerk/clerk-js/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/@clerk/localizations": { + "version": "1.28.10", + "resolved": "https://registry.npmjs.org/@clerk/localizations/-/localizations-1.28.10.tgz", + "integrity": "sha512-eTJ1DJeMKNIie4TB4vE5rqkU1ZJgPG4/j4XTiisFtcag+YUgNxABMuaCdxojIZE5jcKaJ4k1OrUtYFnHAe7TWw==", + "license": "MIT", + "dependencies": { + "@clerk/types": "3.65.5" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@clerk/shared": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-1.4.2.tgz", + "integrity": "sha512-R+OkzCtnNU7sn/F6dBfdY5lKs84TN785VZdBBefmyr7zsXcFEqbCcfQzyvgtIS28Ln5SifFEBoAyYR334IXO8w==", + "license": "MIT", + "dependencies": { + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.1", + "swr": "2.2.0" + }, + "peerDependencies": { + "react": ">=16" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@clerk/shared/node_modules/js-cookie": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.1.tgz", + "integrity": "sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@clerk/types": { + "version": "3.65.5", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-3.65.5.tgz", + "integrity": "sha512-RGO8v2a52Ybo1jwVj42UWT8VKyxAk/qOxrkA3VNIYBNEajPSmZNa9r9MTgqSgZRyz1XTlQHdVb7UK7q78yAGfA==", + "deprecated": "This package is no longer supported. Please import types from @clerk/shared/types instead. See the upgrade guide for more info: https://clerk.com/docs/guides/development/upgrading/upgrade-guides/core-3", + "license": "MIT", + "dependencies": { + "csstype": "3.1.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@clerk/types/node_modules/csstype": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", + "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==", + "license": "MIT" + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -7028,6 +7271,51 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@peculiar/webcrypto": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.4.1.tgz", + "integrity": "sha512-eK4C6WTNYxoI7JOabMoZICiyqRRtJB220bh0Mbj5RwRycleZf9BPyZoxsTvpP0FpmVS2aS13NKOuh5/tN3sIRw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.0", + "@peculiar/json-schema": "^1.1.12", + "pvtsutils": "^1.3.2", + "tslib": "^2.4.1", + "webcrypto-core": "^1.7.4" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/@performant-software/core-data": { "version": "3.1.15", "resolved": "https://registry.npmjs.org/@performant-software/core-data/-/core-data-3.1.15.tgz", @@ -19668,6 +19956,21 @@ "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "license": "MIT" }, + "node_modules/@zxcvbn-ts/core": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/core/-/core-3.0.4.tgz", + "integrity": "sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==", + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "1.0.16" + } + }, + "node_modules/@zxcvbn-ts/language-common": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@zxcvbn-ts/language-common/-/language-common-3.0.4.tgz", + "integrity": "sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==", + "license": "MIT" + }, "node_modules/abbrev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", @@ -20066,6 +20369,26 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/asn1js/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/assertion-error": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.0.0.tgz", @@ -21434,6 +21757,16 @@ "run-parallel-limit": "^1.1.0" } }, + "node_modules/browser-tabs-lock": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.2.15.tgz", + "integrity": "sha512-J8K9vdivK0Di+b8SBdE7EZxDr88TnATing7XoLw6+nFkXMQ6sVBh92K3NQvZlZU91AIkFRi0w3sztk5Z+vsswA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.25.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", @@ -24593,6 +24926,15 @@ "fxparser": "src/cli/cli.js" } }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastest-stable-stringify": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz", @@ -25388,6 +25730,12 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, "node_modules/glob/node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -29028,6 +29376,18 @@ "node": ">=0.10.0" } }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/maplibre-gl": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.18.0.tgz", @@ -50227,6 +50587,39 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvtsutils/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qrcode.react": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-3.1.0.tgz", + "integrity": "sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -54349,6 +54742,32 @@ "tslib": "^2.0.3" } }, + "node_modules/snakecase-keys": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-5.4.4.tgz", + "integrity": "sha512-YTywJG93yxwHLgrYLZjlC75moVEX04LZM4FHfihjHe1FCXm+QaLOFfSf535aXOAd0ArVQMWUAe8ZPm4VtWyXaA==", + "license": "MIT", + "dependencies": { + "map-obj": "^4.1.0", + "snake-case": "^3.0.4", + "type-fest": "^2.5.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/snakecase-keys/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sort-asc": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", @@ -55064,6 +55483,18 @@ "node": ">= 4.7.0" } }, + "node_modules/swr": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.0.tgz", + "integrity": "sha512-AjqHOv2lAhkuUdIiBu9xbuettzAzWXmCEcLONNKJRba87WAefz8Ca9d6ds/SzrPc235n1IxWYdhJ2zF3MNUaoQ==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/tabbable": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", @@ -55366,6 +55797,17 @@ "tinacms": "3.6.1" } }, + "node_modules/tinacms-clerk": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/tinacms-clerk/-/tinacms-clerk-20.0.1.tgz", + "integrity": "sha512-ibXkUYIkE5SgXLKsH6JzTayDkZIyvVJ48fJeh1/uo1yBjMSwjF+fMrnkJK8WSthk56olc6NUPacFe81celH0xw==", + "license": "Apache-2.0", + "peerDependencies": { + "@clerk/backend": "0.x", + "@clerk/clerk-js": "4.x", + "tinacms": "3.6.1" + } + }, "node_modules/tinacms/node_modules/@floating-ui/react": { "version": "0.26.28", "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", @@ -58215,6 +58657,25 @@ "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", "license": "Apache-2.0" }, + "node_modules/webcrypto-core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz", + "integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/json-schema": "^1.1.12", + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.7.0" + } + }, + "node_modules/webcrypto-core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/webfontloader": { "version": "1.6.28", "resolved": "https://registry.npmjs.org/webfontloader/-/webfontloader-1.6.28.tgz", diff --git a/package.json b/package.json index 83aee2b9..a2c77903 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "@astrojs/sitemap": "^3.4.1", "@astrojs/ts-plugin": "^1.10.4", "@auth/core": "^0.37.4", + "@clerk/backend": "^0.38.15", + "@clerk/clerk-js": "^4.73.14", "@cu-mkp/editioncrafter": "^1.3.1-beta.12", "@headlessui/react": "^2.2.0", "@heroicons/react": "^2.1.1", @@ -84,6 +86,7 @@ "tailwindcss": "^4.1.4", "tinacms": "^3.6.1", "tinacms-authjs": "^20.0.1", + "tinacms-clerk": "^20.0.1", "typescript": "^5.9.3", "typesense-instantsearch-adapter": "^2.7.1", "underscore": "^1.13.6", diff --git a/src/backend/tina/index.ts b/src/backend/tina/index.ts index d8631576..8c0919f7 100644 --- a/src/backend/tina/index.ts +++ b/src/backend/tina/index.ts @@ -43,8 +43,12 @@ export const fetchPage = async (locale: string, slug: string) => { } const response = await fetchOne(locale, slug, client.queries.pages); + const page = response.data?.pages; - return response.data?.pages; + // Home page always renders; other pages require published === true + if (page && !page.home_page && page.published !== true) return null; + + return page; }; export const fetchPages = async (locale: string, params?: any) => { @@ -52,19 +56,26 @@ export const fetchPages = async (locale: string, params?: any) => { return null; } - const response = await client.queries.pagesConnection(params); + const response = await client.queries.pagesConnection({ + ...params, + filter: { ...params?.filter, published: { eq: true } } + }); const pages = response.data?.pagesConnection?.edges?.map((item) => item?.node); return filterAll(locale, pages); }; -export const fetchPath = async (slug: string) => { +export const fetchPath = async (slug: string, skipPublishedCheck = false) => { if (!client.queries.path) { return null; } const response = await client.queries.path({ relativePath: `${slug}.mdx`}); - return response.data?.path; + const path = response.data?.path; + + if (!skipPublishedCheck && path && path.published !== true) return null; + + return path; }; export const fetchPathResponse = async (slug: string) => { @@ -81,17 +92,23 @@ export const fetchPaths = async () => { return null; } - const response = await client.queries.pathConnection(); + const response = await client.queries.pathConnection({ + filter: { published: { eq: true } } + }); return response.data?.pathConnection?.edges?.map((item) => item?.node); }; -export const fetchPost = async (slug: string) => { +export const fetchPost = async (slug: string, skipPublishedCheck = false) => { if (!client.queries.post) { return null; } const response = await client.queries.post({ relativePath: `${slug}.mdx`}); - return response.data?.post; + const post = response.data?.post; + + if (!skipPublishedCheck && post && post.published !== true) return null; + + return post; }; export const fetchPostResponse = async (slug: string) => { @@ -103,12 +120,15 @@ export const fetchPostResponse = async (slug: string) => { return response; } -export const fetchPosts = async (params = {}) => { +export const fetchPosts = async (params: any = {}) => { if (!client.queries.postConnection) { return null; } - const response = await client.queries.postConnection(params); + const response = await client.queries.postConnection({ + ...params, + filter: { ...params?.filter, published: { eq: true } } + }); return { metadata: response.data?.postConnection?.pageInfo, diff --git a/src/pages/api/posts/index.json.ts b/src/pages/api/posts/index.json.ts index 971c3619..3db67c23 100644 --- a/src/pages/api/posts/index.json.ts +++ b/src/pages/api/posts/index.json.ts @@ -12,9 +12,7 @@ export const GET: APIRoute = async (req) => { let filter = {}; - if (config.content?.posts_config?.drafts) { - filter['publish'] = { eq: true }; - } + filter['published'] = { eq: true }; if (params?.category) { filter['category'] = { eq: params.category }; diff --git a/src/types.ts b/src/types.ts index b6c835b2..85066822 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,7 +80,6 @@ export interface Configuration { localize_pages?: boolean, posts_config?: { categories?: Array, - drafts?: boolean, layout?: 'list' | 'grid' } }; diff --git a/test/config.test.ts b/test/config.test.ts index 9c57edaa..b3382db5 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -58,9 +58,7 @@ describe('content', () => { expect(config.content?.posts_config?.categories).toBeArrayOf(String); }); - test('posts_config.drafts matches allowed values', () => { - expect(config.content?.posts_config?.drafts).toBeBoolean(); - }); + // drafts field removed — publishing is now handled via the published field on each document }); describe('core_data', () => { diff --git a/tina/clerk-rbac.ts b/tina/clerk-rbac.ts new file mode 100644 index 00000000..1ccca41c --- /dev/null +++ b/tina/clerk-rbac.ts @@ -0,0 +1,113 @@ +// Admin-only collections that editors cannot mutate +const ADMIN_ONLY_COLLECTIONS = ['settings', 'branding', 'i18n', 'navbar', 'user']; + +// Content collections that support ownership +const CONTENT_COLLECTIONS = ['post', 'pages', 'path']; + +// Regex for collection-specific mutations: e.g. updatePost, createPages, deleteSettings +const COLLECTION_MUTATION_RE = /\b(create|update|delete)(Post|Pages|Path|Settings|Branding|I18n|Navbar|User)\b/i; + +// Regex for generic mutations: updateDocument, deleteDocument, addPendingDocument +const GENERIC_MUTATION_RE = /\b(updateDocument|deleteDocument|addPendingDocument)\b/; + +/** + * Lookup owner_id for an existing document. + */ +async function getDocumentOwnerId( + collection: string, + relativePath: string, + databaseClient: any +): Promise { + const queryName: Record = { post: 'post', pages: 'pages', path: 'path' }; + const name = queryName[collection]; + if (!name) return null; + + try { + const result = await databaseClient.request({ + query: `query { ${name}(relativePath: "${relativePath}") { owner_id } }`, + variables: {} + }); + return result?.data?.[name]?.owner_id || null; + } catch { + return null; + } +} + +/** + * Enforce editor rules on GraphQL mutations. + * Returns a rejection message string, or null if the operation is allowed. + */ +export async function enforceEditorRules( + body: { query: string; variables?: any }, + userId: string, + databaseClient: any +): Promise { + const { query, variables } = body; + + // Only check mutations + if (!query.trimStart().startsWith('mutation')) return null; + + // Check collection-specific mutations + const collectionMatch = query.match(COLLECTION_MUTATION_RE); + if (collectionMatch) { + const [, operation, collectionName] = collectionMatch; + const normalized = collectionName.toLowerCase(); + + if (ADMIN_ONLY_COLLECTIONS.includes(normalized)) { + return `Editors cannot modify ${collectionName}`; + } + + // Strip published from params for content collections + if (variables?.params && 'published' in variables.params) { + delete variables.params.published; + } + + // Ownership checks for updates/deletes + if ((operation === 'update' || operation === 'delete') && variables?.relativePath) { + const ownerId = await getDocumentOwnerId(normalized, variables.relativePath, databaseClient); + // Documents with no owner_id are admin-only (pre-migration content) + if (!ownerId) return `This content has no owner and can only be edited by an admin`; + if (ownerId !== userId) return `You can only ${operation} your own content`; + } + + // Inject ownership on creates + if (operation === 'create' && variables?.params) { + variables.params.owner_id = userId; + } + + return null; + } + + // Check generic mutations (updateDocument, deleteDocument, addPendingDocument) + const genericMatch = query.match(GENERIC_MUTATION_RE); + if (genericMatch) { + const collection = variables?.collection?.toLowerCase(); + if (!collection) return null; + + if (ADMIN_ONLY_COLLECTIONS.includes(collection)) { + return `Editors cannot modify ${collection}`; + } + + // Strip published from params + if (variables?.params && 'published' in variables.params) { + delete variables.params.published; + } + + // Ownership checks + const [, mutationType] = genericMatch; + if ((mutationType === 'updateDocument' || mutationType === 'deleteDocument') && variables?.relativePath) { + const ownerId = await getDocumentOwnerId(collection, variables.relativePath, databaseClient); + if (!ownerId) return `This content has no owner and can only be edited by an admin`; + if (ownerId !== userId) return `You can only modify your own content`; + } + + // Inject ownership on addPendingDocument + if (mutationType === 'addPendingDocument' && variables?.params) { + variables.params.owner_id = userId; + } + + return null; + } + + return null; +} diff --git a/tina/components/PublishedToggle.tsx b/tina/components/PublishedToggle.tsx new file mode 100644 index 00000000..f809af3b --- /dev/null +++ b/tina/components/PublishedToggle.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const PublishedToggle = (props: any) => { + const role = typeof document !== 'undefined' + ? document.body.dataset.tinaRole + : 'admin'; + + // Editors cannot see or set the published field + if (role === 'editor') return null; + + return ( +
+ + props.input.onChange(e.target.checked)} + /> +
+ ); +}; + +export default PublishedToggle; diff --git a/tina/components/ReadOnlyText.tsx b/tina/components/ReadOnlyText.tsx new file mode 100644 index 00000000..2a36bfce --- /dev/null +++ b/tina/components/ReadOnlyText.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +const ReadOnlyText = (props: any) => { + if (!props.input.value) return null; + + return ( +
+ + {props.input.value} +
+ ); +}; + +export default ReadOnlyText; diff --git a/tina/config.ts b/tina/config.ts index dfd5c495..d3cd4bf2 100644 --- a/tina/config.ts +++ b/tina/config.ts @@ -14,17 +14,42 @@ import { TinaUserCollection, UsernamePasswordAuthJSProvider } from 'tinacms-auth const isLocal = process.env.TINA_PUBLIC_IS_LOCAL === 'true'; const localContentPath = process.env.TINA_LOCAL_CONTENT_PATH; const useSSO = process.env.TINA_PUBLIC_AUTH_USE_KEYCLOAK === 'true'; +const useClerk = process.env.TINA_PUBLIC_AUTH_USE_CLERK === 'true'; + +const getAuthProvider = async () => { + if (isLocal) return new LocalAuthProvider(); + + if (useClerk) { + const { default: Clerk } = await import('@clerk/clerk-js'); + const { ClerkAuthProvider } = await import('tinacms-clerk/dist/tinacms'); + const clerkInstance = new Clerk(process.env.TINA_PUBLIC_CLERK_PUBLISHABLE_KEY!); + await clerkInstance.load(); + return new ClerkAuthProvider({ clerk: clerkInstance }); + } + + if (useSSO) return new CustomAuthProvider(); + + return new UsernamePasswordAuthJSProvider(); +}; export default defineConfig({ - authProvider: isLocal - ? new LocalAuthProvider() - : useSSO - ? new CustomAuthProvider() - : new UsernamePasswordAuthJSProvider(), + authProvider: await getAuthProvider(), build: { outputFolder: 'admin', publicFolder: 'public', }, + cmsCallback: (cms) => { + import('./role-ui').then(({ applyEditorRestrictions }) => { + applyEditorRestrictions(cms); + }); + + const { default: PublishedToggle } = require('./components/PublishedToggle'); + const { default: ReadOnlyText } = require('./components/ReadOnlyText'); + cms.fields.add({ name: 'PublishedToggle', Component: PublishedToggle }); + cms.fields.add({ name: 'ReadOnlyText', Component: ReadOnlyText }); + + return cms; + }, contentApiUrlOverride: '/api/tina/gql', localContentPath, media: { @@ -33,10 +58,9 @@ export default defineConfig({ return pack.TinaCloudS3MediaStore; } }, - // See docs on content modeling for more info on how to setup new content models: https://tina.io/docs/schema/ schema: { collections: _.compact([ - !useSSO + !useSSO && !useClerk ? TinaUserCollection : undefined, Branding, diff --git a/tina/content/pages.ts b/tina/content/pages.ts index 1f415b70..5142d6ae 100644 --- a/tina/content/pages.ts +++ b/tina/content/pages.ts @@ -925,12 +925,36 @@ const Pages: Collection = { label: 'Pages', path: 'content/pages', format: 'mdx', + ui: { + beforeSubmit: async ({ values, cms }: any) => { + const user = await cms.authProvider?.getUser?.(); + if (user?.id && !values.owner_id) { + values.owner_id = user.id; + values.owner_name = user.fullName || user.primaryEmailAddress?.emailAddress || ''; + } + return values; + }, + }, fields: [{ name: 'title', label: 'Title', type: 'string', isTitle: true, required: true + }, { + name: 'published', + label: 'Published', + type: 'boolean', + ui: { component: 'PublishedToggle' } + }, { + name: 'owner_id', + type: 'string', + ui: { component: () => null } + }, { + name: 'owner_name', + label: 'Created by', + type: 'string', + ui: { component: 'ReadOnlyText' } }, { name: 'home_page', label: 'Home Page', diff --git a/tina/content/paths.ts b/tina/content/paths.ts index 6f3eac92..7f6dd150 100644 --- a/tina/content/paths.ts +++ b/tina/content/paths.ts @@ -15,9 +15,17 @@ const Paths: Collection = { const hashArray = new Uint8Array(hash); const hashHex = Array.from(hashArray) .map(byte => byte.toString(16).padStart(2, '0')) - .join(''); + .join(''); return `/en/paths/${hashHex}/preview/${document._sys.filename}`; }, + beforeSubmit: async ({ values, cms }: any) => { + const user = await cms.authProvider?.getUser?.(); + if (user?.id && !values.owner_id) { + values.owner_id = user.id; + values.owner_name = user.fullName || user.primaryEmailAddress?.emailAddress || ''; + } + return values; + }, }, fields: [ { @@ -47,6 +55,23 @@ const Paths: Collection = { label: 'Date', type: 'datetime' }, + { + name: 'published', + label: 'Published', + type: 'boolean', + ui: { component: 'PublishedToggle' } + }, + { + name: 'owner_id', + type: 'string', + ui: { component: () => null } + }, + { + name: 'owner_name', + label: 'Created by', + type: 'string', + ui: { component: 'ReadOnlyText' } + }, { name: 'description', label: 'Description', diff --git a/tina/content/posts.ts b/tina/content/posts.ts index 65a29435..82db2186 100644 --- a/tina/content/posts.ts +++ b/tina/content/posts.ts @@ -47,9 +47,17 @@ const Posts: Collection = { const hashArray = new Uint8Array(hash); const hashHex = Array.from(hashArray) .map(byte => byte.toString(16).padStart(2, '0')) - .join(''); + .join(''); return `/en/posts/${hashHex}/preview/${document._sys.filename}`; }, + beforeSubmit: async ({ values, cms }: any) => { + const user = await cms.authProvider?.getUser?.(); + if (user?.id && !values.owner_id) { + values.owner_id = user.id; + values.owner_name = user.fullName || user.primaryEmailAddress?.emailAddress || ''; + } + return values; + }, }, fields: _.compact([ ...postMetadata, @@ -63,10 +71,22 @@ const Posts: Collection = { label: 'Card Image alt text', type: 'string' }, - config.content?.posts_config?.drafts && { - name: 'publish', - label: 'Publish', - type: 'boolean' + { + name: 'published', + label: 'Published', + type: 'boolean', + ui: { component: 'PublishedToggle' } + }, + { + name: 'owner_id', + type: 'string', + ui: { component: () => null } + }, + { + name: 'owner_name', + label: 'Created by', + type: 'string', + ui: { component: 'ReadOnlyText' } }, { type: 'rich-text', diff --git a/tina/content/settings.ts b/tina/content/settings.ts index a151fe81..60dba7c8 100644 --- a/tina/content/settings.ts +++ b/tina/content/settings.ts @@ -70,10 +70,6 @@ const Settings: Collection = { label: 'Categories', type: 'string', list: true - }, { - name: 'drafts', - label: 'Use Draft Workflow?', - type: 'boolean' }, { name: 'sort_by', label: 'Post display order', diff --git a/tina/role-ui.ts b/tina/role-ui.ts new file mode 100644 index 00000000..4dd123b6 --- /dev/null +++ b/tina/role-ui.ts @@ -0,0 +1,31 @@ +const ADMIN_ONLY_COLLECTIONS = ['Settings', 'Branding', 'I18n', 'Navbar']; + +export const applyEditorRestrictions = async (cms: any) => { + const user = await cms.authProvider?.getUser?.(); + const role = user?.publicMetadata?.role || 'editor'; + + if (typeof document !== 'undefined') { + document.body.dataset.tinaRole = role; + } + + if (role !== 'editor') return; + + // Use MutationObserver to hide admin-only sidebar links. + // This is cosmetic — backend enforcement is the security layer. + if (typeof MutationObserver === 'undefined') return; + + const hideSidebarItems = () => { + const links = document.querySelectorAll('a[href*="/admin"]'); + links.forEach((link) => { + const text = link.textContent?.trim(); + if (text && ADMIN_ONLY_COLLECTIONS.includes(text)) { + (link as HTMLElement).style.display = 'none'; + } + }); + }; + + // Run once immediately and observe for DOM changes + hideSidebarItems(); + const observer = new MutationObserver(hideSidebarItems); + observer.observe(document.body, { childList: true, subtree: true }); +};