├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .prettierrc.js ├── README.md ├── demo ├── nodemon.json ├── package.json ├── src │ ├── collections │ │ ├── Pages.ts │ │ └── Users.ts │ ├── payload.config.ts │ └── server.ts ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── index.ts ├── types.ts └── utilities │ ├── getCookiePrefix.ts │ ├── getMutation.ts │ ├── getRouter.ts │ ├── operation.ts │ ├── parseCookies.ts │ └── webpackMock.js ├── tsconfig.json ├── yarn-error.log └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | end_of_line = lf 10 | max_line_length = null 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@payloadcms'], 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | dist 4 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | parser: "typescript", 4 | semi: false, 5 | singleQuote: true, 6 | trailingComma: "all", 7 | arrowParens: "avoid", 8 | }; 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payload Password Protection Plugin 2 | 3 | [![NPM](https://img.shields.io/npm/v/payload-plugin-password-protection)](https://www.npmjs.com/package/payload-plugin-password-protection) 4 | 5 | A plugin for [Payload](https://github.com/payloadcms/payload) to easily allow for documents to be secured behind a layer of password protection. 6 | 7 | ## Installation 8 | 9 | ```bash 10 | yarn add payload-plugin-password-protection 11 | # OR 12 | npm i payload-plugin-password-protection 13 | ``` 14 | 15 | ## Basic Usage 16 | 17 | In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options): 18 | 19 | ```js 20 | import { buildConfig } from 'payload/config'; 21 | import passwordProtection from 'payload-plugin-password-protection'; 22 | 23 | const config = buildConfig({ 24 | collections: [ 25 | plugins: [ 26 | passwordProtection({ 27 | collections: ['pages'], 28 | }) 29 | ] 30 | }); 31 | 32 | export default config; 33 | ``` 34 | 35 | ### Options 36 | 37 | #### `collections` 38 | 39 | An array of collections slugs to enable password protection. 40 | 41 | ## TypeScript 42 | 43 | All types can be directly imported: 44 | 45 | ```js 46 | import { PasswordProtectionConfig } from "payload-plugin-password-protection/dist/types"; 47 | ``` 48 | 49 | ## Screenshots 50 | 51 | 52 | -------------------------------------------------------------------------------- /demo/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "ts", 3 | "exec": "ts-node src/server.ts" 4 | } 5 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-starter-typescript", 3 | "description": "Blank template - no collections", 4 | "version": "1.0.0", 5 | "main": "dist/server.js", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon", 9 | "build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build", 10 | "build:server": "tsc", 11 | "build": "yarn build:payload && yarn build:server", 12 | "serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js", 13 | "generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types" 14 | }, 15 | "dependencies": { 16 | "dotenv": "^8.2.0", 17 | "express": "^4.17.1", 18 | "payload": "^1.8.2" 19 | }, 20 | "devDependencies": { 21 | "@types/express": "^4.17.9", 22 | "cross-env": "^7.0.3", 23 | "nodemon": "^2.0.6", 24 | "ts-node": "^9.1.1", 25 | "typescript": "^4.1.3" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/collections/Pages.ts: -------------------------------------------------------------------------------- 1 | // const payload = require('payload'); 2 | import type { CollectionConfig } from 'payload/types' 3 | 4 | export const Pages: CollectionConfig = { 5 | slug: 'pages', 6 | labels: { 7 | singular: 'Page', 8 | plural: 'Pages', 9 | }, 10 | admin: { 11 | useAsTitle: 'title', 12 | }, 13 | fields: [ 14 | { 15 | name: 'title', 16 | label: 'Title', 17 | type: 'text', 18 | required: true, 19 | }, 20 | { 21 | name: 'slug', 22 | label: 'Slug', 23 | type: 'text', 24 | required: true, 25 | }, 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /demo/src/collections/Users.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload/types' 2 | 3 | export const Users: CollectionConfig = { 4 | slug: 'users', 5 | auth: true, 6 | admin: { 7 | useAsTitle: 'email', 8 | }, 9 | access: { 10 | read: () => true, 11 | }, 12 | fields: [ 13 | // Email added by default 14 | // Add more fields as needed 15 | ], 16 | } 17 | -------------------------------------------------------------------------------- /demo/src/payload.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { buildConfig } from 'payload/config' 3 | 4 | // import passwordProtection from '../../dist'; 5 | import passwordProtection from '../../src' 6 | import { Pages } from './collections/Pages' 7 | import { Users } from './collections/Users' 8 | 9 | export default buildConfig({ 10 | serverURL: 'http://localhost:3000', 11 | admin: { 12 | user: Users.slug, 13 | webpack: config => { 14 | const newConfig = { 15 | ...config, 16 | resolve: { 17 | ...config.resolve, 18 | alias: { 19 | ...config.resolve.alias, 20 | react: path.join(__dirname, '../node_modules/react'), 21 | 'react-dom': path.join(__dirname, '../node_modules/react-dom'), 22 | payload: path.join(__dirname, '../node_modules/payload'), 23 | }, 24 | }, 25 | } 26 | 27 | return newConfig 28 | }, 29 | }, 30 | collections: [Users, Pages], 31 | plugins: [ 32 | passwordProtection({ 33 | collections: ['pages'], 34 | }), 35 | ], 36 | typescript: { 37 | outputFile: path.resolve(__dirname, 'payload-types.ts'), 38 | }, 39 | }) 40 | -------------------------------------------------------------------------------- /demo/src/server.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | import express from 'express' 5 | import payload from 'payload' 6 | 7 | const app = express() 8 | 9 | // Redirect root to Admin panel 10 | app.get('/', (_, res) => { 11 | res.redirect('/admin') 12 | }) 13 | 14 | // Initialize Payload 15 | const start = async (): Promise => { 16 | await payload.init({ 17 | secret: process.env.PAYLOAD_SECRET, 18 | mongoURL: process.env.MONGODB_URI, 19 | express: app, 20 | onInit: () => { 21 | payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`) 22 | }, 23 | }) 24 | 25 | app.listen(3000) 26 | } 27 | 28 | start() 29 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "strict": false, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "outDir": "./dist", 13 | "rootDir": "../", 14 | "jsx": "react", 15 | }, 16 | "ts-node": { 17 | "transpileOnly": true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-plugin-password-protection", 3 | "version": "0.0.2", 4 | "description": "Password protection plugin for Payload", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "lint": "eslint src", 11 | "lint:fix": "eslint --fix --ext .ts,.tsx src" 12 | }, 13 | "keywords": [ 14 | "payload", 15 | "cms", 16 | "plugin", 17 | "typescript", 18 | "react", 19 | "password", 20 | "protection", 21 | "pages" 22 | ], 23 | "author": "dev@trbl.design", 24 | "license": "MIT", 25 | "peerDependencies": { 26 | "payload": "^0.15.0", 27 | "react": "^16.8.0 || ^17.0.0 || ^18.0.0" 28 | }, 29 | "devDependencies": { 30 | "@payloadcms/eslint-config": "^0.0.1", 31 | "@types/escape-html": "^1.0.1", 32 | "@types/express": "^4.17.9", 33 | "@types/node": "18.11.3", 34 | "@types/react": "18.0.21", 35 | "@typescript-eslint/eslint-plugin": "^5.51.0", 36 | "@typescript-eslint/parser": "^5.51.0", 37 | "copyfiles": "^2.4.1", 38 | "cross-env": "^7.0.3", 39 | "eslint": "^8.19.0", 40 | "eslint-config-prettier": "^8.5.0", 41 | "eslint-plugin-filenames": "^1.3.2", 42 | "eslint-plugin-import": "2.25.4", 43 | "eslint-plugin-prettier": "^4.0.0", 44 | "eslint-plugin-react-hooks": "^4.6.0", 45 | "eslint-plugin-simple-import-sort": "^10.0.0", 46 | "nodemon": "^2.0.6", 47 | "payload": "^1.8.2", 48 | "prettier": "^2.7.1", 49 | "react": "^18.0.0", 50 | "ts-node": "^9.1.1", 51 | "typescript": "^4.8.4" 52 | }, 53 | "files": [ 54 | "dist" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import type { Config } from 'payload/config' 3 | import type { CollectionConfig } from 'payload/dist/collections/config/types' 4 | import type { CollectionBeforeReadHook } from 'payload/types' 5 | 6 | import type { PasswordProtectionConfig } from './types' 7 | import getCookiePrefix from './utilities/getCookiePrefix' 8 | import getMutation from './utilities/getMutation' 9 | import getRouter from './utilities/getRouter' 10 | import parseCookies from './utilities/parseCookies' 11 | 12 | const collectionPasswords = 13 | (incomingOptions: PasswordProtectionConfig) => 14 | (incomingConfig: Config): Config => { 15 | const { collections } = incomingOptions 16 | 17 | const options = { 18 | collections, 19 | routePath: incomingOptions.routePath || '/validate-password', 20 | expiration: incomingOptions.expiration || 7200, 21 | whitelistUsers: 22 | incomingOptions.whitelistUsers || 23 | (({ payloadAPI, user }) => Boolean(user) || payloadAPI === 'local'), 24 | passwordFieldName: incomingOptions.passwordFieldName || 'docPassword', 25 | passwordProtectedFieldName: incomingOptions.passwordFieldName || 'passwordProtected', 26 | mutationName: incomingOptions.mutationName || 'validatePassword', 27 | } 28 | 29 | const config: Config = { 30 | ...incomingConfig, 31 | graphQL: { 32 | ...incomingConfig.graphQL, 33 | mutations: (GraphQL, payload) => ({ 34 | ...(typeof incomingConfig?.graphQL?.mutations === 'function' 35 | ? incomingConfig.graphQL.mutations(GraphQL, payload) 36 | : {}), 37 | [options.mutationName]: getMutation(GraphQL, payload, incomingConfig, options), 38 | }), 39 | }, 40 | express: { 41 | ...incomingConfig?.express, 42 | middleware: [ 43 | ...(incomingConfig?.express?.middleware || []), 44 | getRouter(incomingConfig, options), 45 | ], 46 | }, 47 | admin: { 48 | ...incomingConfig.admin, 49 | webpack: webpackConfig => { 50 | let newWebpackConfig = { ...webpackConfig } 51 | if (typeof incomingConfig?.admin?.webpack === 'function') 52 | newWebpackConfig = incomingConfig.admin.webpack(webpackConfig) 53 | 54 | const webpackMock = path.resolve(__dirname, './utilities/webpackMock.js') 55 | 56 | return { 57 | ...newWebpackConfig, 58 | resolve: { 59 | ...newWebpackConfig.resolve, 60 | alias: { 61 | ...(newWebpackConfig?.resolve?.alias || {}), 62 | [path.resolve(__dirname, 'utilities/getRouter')]: webpackMock, 63 | [path.resolve(__dirname, 'utilities/getMutation')]: webpackMock, 64 | }, 65 | }, 66 | } 67 | }, 68 | }, 69 | } 70 | 71 | config.collections = 72 | config?.collections?.map(collectionConfig => { 73 | if (collections?.includes(collectionConfig.slug)) { 74 | const cookiePrefix = getCookiePrefix(config.cookiePrefix || '', collectionConfig.slug) 75 | 76 | const beforeReadHook: CollectionBeforeReadHook = async ({ req, doc }) => { 77 | const whitelistUsersResponse = 78 | typeof options.whitelistUsers === 'function' 79 | ? await options.whitelistUsers(req) 80 | : false 81 | 82 | if (!doc[options.passwordFieldName] || whitelistUsersResponse) return doc 83 | 84 | const cookies = parseCookies(req) 85 | const cookiePassword = cookies[`${cookiePrefix}-${doc.id}`] 86 | 87 | if (cookiePassword === doc[options.passwordFieldName]) { 88 | return doc 89 | } 90 | 91 | return { 92 | id: doc.id, 93 | [options.passwordProtectedFieldName]: true, 94 | } 95 | } 96 | 97 | const collectionWithPasswordProtection: CollectionConfig = { 98 | ...collectionConfig, 99 | hooks: { 100 | ...collectionConfig?.hooks, 101 | beforeRead: [...(collectionConfig?.hooks?.beforeRead || []), beforeReadHook], 102 | }, 103 | fields: [ 104 | ...collectionConfig?.fields.map(field => { 105 | const newField = { ...field } 106 | newField.admin = { 107 | ...newField.admin, 108 | condition: (data, siblingData) => { 109 | const existingConditionResult = field?.admin?.condition 110 | ? field.admin.condition(data, siblingData) 111 | : true 112 | return data?.[options.passwordProtectedFieldName] 113 | ? false 114 | : existingConditionResult 115 | }, 116 | } 117 | 118 | return newField 119 | }), 120 | { 121 | name: options.passwordFieldName, 122 | label: 'Password', 123 | type: 'text', 124 | admin: { 125 | position: 'sidebar', 126 | }, 127 | }, 128 | { 129 | name: options.passwordProtectedFieldName, 130 | type: 'checkbox', 131 | hooks: { 132 | beforeChange: [({ value }) => (value ? null : undefined)], 133 | }, 134 | admin: { 135 | disabled: true, 136 | }, 137 | }, 138 | ], 139 | } 140 | 141 | return collectionWithPasswordProtection 142 | } 143 | 144 | return collectionConfig 145 | }) || [] 146 | 147 | return config 148 | } 149 | 150 | export default collectionPasswords 151 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { PayloadRequest } from 'payload/dist/express/types' 2 | 3 | export type AllowUsers = (req: PayloadRequest) => Promise | boolean 4 | 5 | export interface PasswordProtectionConfig { 6 | passwordFieldName?: string 7 | passwordProtectedFieldName?: string 8 | whitelistUsers?: AllowUsers 9 | routePath?: string 10 | mutationName?: string 11 | expiration?: number 12 | collections?: string[] 13 | } 14 | 15 | export interface PasswordProtectionOptions { 16 | collections?: string[] 17 | routePath: string 18 | expiration: number 19 | whitelistUsers: AllowUsers 20 | passwordFieldName: string 21 | passwordProtectedFieldName: string 22 | mutationName: string 23 | } 24 | -------------------------------------------------------------------------------- /src/utilities/getCookiePrefix.ts: -------------------------------------------------------------------------------- 1 | export default (cookiePrefix: string, collectionSlug: string): string => 2 | `${cookiePrefix}-${collectionSlug}-password-` 3 | -------------------------------------------------------------------------------- /src/utilities/getMutation.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import type { Response } from 'express' 3 | import type { GraphQLFieldConfig } from 'graphql' 4 | import type GraphQL from 'graphql' 5 | import type { Payload } from 'payload' 6 | import type { Config } from 'payload/config' 7 | import type { PayloadRequest } from 'payload/dist/express/types' 8 | 9 | import type { PasswordProtectionOptions } from '../types' 10 | import operation from './operation' 11 | 12 | interface Args { 13 | collection: string 14 | password: string 15 | id: string 16 | } 17 | 18 | type MutationType = GraphQLFieldConfig 19 | 20 | const getMutation = ( 21 | GraphQLArg: typeof GraphQL, 22 | payload: Payload, 23 | config: Config, 24 | options: PasswordProtectionOptions, 25 | ): MutationType => { 26 | const { GraphQLBoolean, GraphQLString, GraphQLNonNull } = GraphQLArg 27 | 28 | return { 29 | type: GraphQLBoolean, 30 | args: { 31 | collection: { 32 | type: new GraphQLNonNull(GraphQLString), 33 | }, 34 | password: { 35 | type: new GraphQLNonNull(GraphQLString), 36 | }, 37 | id: { 38 | type: new GraphQLNonNull(GraphQLString), 39 | }, 40 | }, 41 | resolve: async (_, args, context) => { 42 | const { collection, password, id } = args 43 | 44 | try { 45 | await operation({ 46 | config, 47 | payload, 48 | options, 49 | collection, 50 | password, 51 | id, 52 | res: context.res, 53 | }) 54 | 55 | return true 56 | } catch { 57 | return false 58 | } 59 | }, 60 | } 61 | } 62 | 63 | export default getMutation 64 | -------------------------------------------------------------------------------- /src/utilities/getRouter.ts: -------------------------------------------------------------------------------- 1 | import type { Router } from 'express' 2 | import express from 'express' 3 | import type { Config as PayloadConfig } from 'payload/config' 4 | import type { PayloadRequest } from 'payload/dist/express/types' 5 | 6 | import type { PasswordProtectionOptions } from '../types' 7 | import operation from './operation' 8 | 9 | export default (config: PayloadConfig, options: PasswordProtectionOptions): Router => { 10 | const router = express.Router() 11 | 12 | // TODO: the second argument of router.post() needs to be typed correctly 13 | // @ts-expect-error 14 | router.post(options.routePath || '/validate-password', async (req: PayloadRequest, res) => { 15 | try { 16 | const { body: { collection, password, id } = {}, payload } = req 17 | 18 | await operation({ 19 | config, 20 | payload, 21 | options, 22 | collection, 23 | password, 24 | id, 25 | res, 26 | }) 27 | 28 | res.status(200).send() 29 | } catch (e: unknown) { 30 | res.status(401).send() 31 | } 32 | }) 33 | 34 | return router 35 | } 36 | -------------------------------------------------------------------------------- /src/utilities/operation.ts: -------------------------------------------------------------------------------- 1 | import type { Response } from 'express' 2 | import type { Payload } from 'payload' 3 | import type { Config as PayloadConfig } from 'payload/config' 4 | import APIError from 'payload/dist/errors/APIError' 5 | 6 | import type { PasswordProtectionOptions } from '../types' 7 | import getCookiePrefix from './getCookiePrefix' 8 | 9 | interface Args { 10 | config: PayloadConfig 11 | payload: Payload 12 | options: PasswordProtectionOptions 13 | collection: string 14 | password: string 15 | id: string 16 | res: Response 17 | } 18 | 19 | const validatePassword = async ({ 20 | config, 21 | payload, 22 | options, 23 | collection, 24 | password, 25 | id, 26 | res, 27 | }: Args): Promise => { 28 | const doc = await payload.findByID({ 29 | id, 30 | collection, 31 | }) 32 | 33 | if (doc[options.passwordFieldName] === password) { 34 | const expires = new Date() 35 | expires.setSeconds(expires.getSeconds() + options.expiration || 7200) 36 | 37 | const cookiePrefix = getCookiePrefix(config.cookiePrefix || '', collection) 38 | 39 | const cookieOptions = { 40 | path: '/', 41 | httpOnly: true, 42 | expires, 43 | domain: undefined, 44 | } 45 | 46 | res.cookie(`${cookiePrefix}-${id}`, password, cookieOptions) 47 | return 48 | } 49 | 50 | throw new APIError('The password provided is incorrect.', 400) 51 | } 52 | 53 | export default validatePassword 54 | -------------------------------------------------------------------------------- /src/utilities/parseCookies.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express' 2 | 3 | function parseCookies(req: Request): { [key: string]: string } { 4 | const list: { [key: string]: any } = {} 5 | const rc = req.headers.cookie 6 | 7 | if (rc) { 8 | rc.split(';').forEach(cookie => { 9 | const parts = cookie.split('=') 10 | const keyToUse = parts.shift()?.trim() || '' 11 | list[keyToUse] = decodeURI(parts.join('=')) 12 | }) 13 | } 14 | 15 | return list 16 | } 17 | 18 | export default parseCookies 19 | -------------------------------------------------------------------------------- /src/utilities/webpackMock.js: -------------------------------------------------------------------------------- 1 | module.exports = () => config => config 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "outDir": "./dist", 5 | "allowJs": true, 6 | "module": "commonjs", 7 | "sourceMap": true, 8 | "jsx": "react", 9 | "esModuleInterop": true, 10 | "declaration": true, 11 | "declarationDir": "./dist", 12 | "skipLibCheck": true, 13 | "strict": true, 14 | }, 15 | "include": [ 16 | "src/**/*" 17 | ], 18 | } 19 | --------------------------------------------------------------------------------