Compare commits

...

15 Commits

Author SHA1 Message Date
Jacob Fletcher
69ae87b866 Merge remote-tracking branch 'plugin-password-protection/main' into chore/plugin-password-protection 2023-10-15 02:30:23 -04:00
Jacob Fletcher
9918ab0cdd Merge pull request #1 from payloadcms/chore/eslint
chore: eslint and prettier
2023-05-18 14:41:09 -04:00
Jacob Fletcher
38258d575e chore: bumps demo to payload v1.8.2 2023-05-18 13:55:38 -04:00
Jacob Fletcher
1eac936008 chore: eslint and prettier 2023-05-18 13:53:44 -04:00
Jacob Fletcher
a3b3cb38cd 0.0.2 2022-05-04 11:10:47 -04:00
Jacob Fletcher
b6792b7855 chore: updates react peerDep 2022-05-04 11:10:35 -04:00
Jacob Fletcher
ac67fdc3d6 fix: type errors 2022-03-17 00:25:26 -04:00
Jacob Fletcher
a7b2638d52 feat: initializes standalone repository 2022-03-17 00:00:55 -04:00
James
850af9b1a8 fix: safely accesses existing graphql mutations in password protection plugin 2021-08-02 16:36:26 -04:00
Jarrod Flesch
7d0603046d fix: adjusts plugin architecture to be mindful of the passwordProtectedFieldName 2021-04-23 10:33:15 -04:00
Jarrod Flesch
37b2abf0e6 fix: adjusts password protection condition 2021-04-23 09:56:50 -04:00
Jarrod Flesch
d615ff826a fix: conditional logic for admin ui 2021-04-23 01:20:36 -04:00
Sean Zubrickas
dec5180bca Removes condition for blank home page fix 2021-04-22 18:13:29 -04:00
Jarrod Flesch
0dea38dfa4 fix: tweaks types and variable names for gql 2021-04-22 11:14:02 -04:00
James
045657d918 feat: builds password plugin 2021-04-21 21:48:28 -04:00
25 changed files with 24347 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
root = true
[*]
indent_style = space
indent_size = 2
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf
max_line_length = null

View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['@payloadcms'],
}

View File

@@ -0,0 +1,3 @@
node_modules
.env
dist

View File

@@ -0,0 +1,8 @@
module.exports = {
printWidth: 100,
parser: "typescript",
semi: false,
singleQuote: true,
trailingComma: "all",
arrowParens: "avoid",
};

View File

@@ -0,0 +1,51 @@
# Payload Password Protection Plugin
[![NPM](https://img.shields.io/npm/v/payload-plugin-password-protection)](https://www.npmjs.com/package/payload-plugin-password-protection)
A plugin for [Payload](https://github.com/payloadcms/payload) to easily allow for documents to be secured behind a layer of password protection.
## Installation
```bash
yarn add payload-plugin-password-protection
# OR
npm i payload-plugin-password-protection
```
## Basic Usage
In the `plugins` array of your [Payload config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options):
```js
import { buildConfig } from 'payload/config';
import passwordProtection from 'payload-plugin-password-protection';
const config = buildConfig({
collections: [
plugins: [
passwordProtection({
collections: ['pages'],
})
]
});
export default config;
```
### Options
#### `collections`
An array of collections slugs to enable password protection.
## TypeScript
All types can be directly imported:
```js
import { PasswordProtectionConfig } from "payload-plugin-password-protection/dist/types";
```
## Screenshots
<!-- ![screenshot 1](https://github.com/trouble/payload-plugin-password-protection/blob/main/images/screenshot-1.jpg?raw=true) -->

View File

@@ -0,0 +1,4 @@
{
"ext": "ts",
"exec": "ts-node src/server.ts"
}

View File

@@ -0,0 +1,27 @@
{
"name": "payload-starter-typescript",
"description": "Blank template - no collections",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",
"scripts": {
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
"build:server": "tsc",
"build": "yarn build:payload && yarn build:server",
"serve": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NODE_ENV=production node dist/server.js",
"generate:types": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types"
},
"dependencies": {
"dotenv": "^8.2.0",
"express": "^4.17.1",
"payload": "^1.8.2"
},
"devDependencies": {
"@types/express": "^4.17.9",
"cross-env": "^7.0.3",
"nodemon": "^2.0.6",
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
}
}

View File

@@ -0,0 +1,27 @@
// const payload = require('payload');
import type { CollectionConfig } from 'payload/types'
export const Pages: CollectionConfig = {
slug: 'pages',
labels: {
singular: 'Page',
plural: 'Pages',
},
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
{
name: 'slug',
label: 'Slug',
type: 'text',
required: true,
},
],
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload/types'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
access: {
read: () => true,
},
fields: [
// Email added by default
// Add more fields as needed
],
}

View File

@@ -0,0 +1,39 @@
import path from 'path'
import { buildConfig } from 'payload/config'
// import passwordProtection from '../../dist';
import passwordProtection from '../../src'
import { Pages } from './collections/Pages'
import { Users } from './collections/Users'
export default buildConfig({
serverURL: 'http://localhost:3000',
admin: {
user: Users.slug,
webpack: config => {
const newConfig = {
...config,
resolve: {
...config.resolve,
alias: {
...config.resolve.alias,
react: path.join(__dirname, '../node_modules/react'),
'react-dom': path.join(__dirname, '../node_modules/react-dom'),
payload: path.join(__dirname, '../node_modules/payload'),
},
},
}
return newConfig
},
},
collections: [Users, Pages],
plugins: [
passwordProtection({
collections: ['pages'],
}),
],
typescript: {
outputFile: path.resolve(__dirname, 'payload-types.ts'),
},
})

View File

@@ -0,0 +1,28 @@
import dotenv from 'dotenv'
dotenv.config()
import express from 'express'
import payload from 'payload'
const app = express()
// Redirect root to Admin panel
app.get('/', (_, res) => {
res.redirect('/admin')
})
// Initialize Payload
const start = async (): Promise<void> => {
await payload.init({
secret: process.env.PAYLOAD_SECRET,
mongoURL: process.env.MONGODB_URI,
express: app,
onInit: () => {
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
},
})
app.listen(3000)
}
start()

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "../",
"jsx": "react",
},
"ts-node": {
"transpileOnly": true
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
{
"name": "payload-plugin-password-protection",
"version": "0.0.2",
"description": "Password protection plugin for Payload",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint src",
"lint:fix": "eslint --fix --ext .ts,.tsx src"
},
"keywords": [
"payload",
"cms",
"plugin",
"typescript",
"react",
"password",
"protection",
"pages"
],
"author": "dev@trbl.design",
"license": "MIT",
"peerDependencies": {
"payload": "^0.15.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"devDependencies": {
"@payloadcms/eslint-config": "^0.0.1",
"@types/escape-html": "^1.0.1",
"@types/express": "^4.17.9",
"@types/node": "18.11.3",
"@types/react": "18.0.21",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"eslint": "^8.19.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-filenames": "^1.3.2",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-simple-import-sort": "^10.0.0",
"nodemon": "^2.0.6",
"payload": "^1.8.2",
"prettier": "^2.7.1",
"react": "^18.0.0",
"ts-node": "^9.1.1",
"typescript": "^4.8.4"
},
"files": [
"dist"
]
}

View File

@@ -0,0 +1,150 @@
import path from 'path'
import type { Config } from 'payload/config'
import type { CollectionConfig } from 'payload/dist/collections/config/types'
import type { CollectionBeforeReadHook } from 'payload/types'
import type { PasswordProtectionConfig } from './types'
import getCookiePrefix from './utilities/getCookiePrefix'
import getMutation from './utilities/getMutation'
import getRouter from './utilities/getRouter'
import parseCookies from './utilities/parseCookies'
const collectionPasswords =
(incomingOptions: PasswordProtectionConfig) =>
(incomingConfig: Config): Config => {
const { collections } = incomingOptions
const options = {
collections,
routePath: incomingOptions.routePath || '/validate-password',
expiration: incomingOptions.expiration || 7200,
whitelistUsers:
incomingOptions.whitelistUsers ||
(({ payloadAPI, user }) => Boolean(user) || payloadAPI === 'local'),
passwordFieldName: incomingOptions.passwordFieldName || 'docPassword',
passwordProtectedFieldName: incomingOptions.passwordFieldName || 'passwordProtected',
mutationName: incomingOptions.mutationName || 'validatePassword',
}
const config: Config = {
...incomingConfig,
graphQL: {
...incomingConfig.graphQL,
mutations: (GraphQL, payload) => ({
...(typeof incomingConfig?.graphQL?.mutations === 'function'
? incomingConfig.graphQL.mutations(GraphQL, payload)
: {}),
[options.mutationName]: getMutation(GraphQL, payload, incomingConfig, options),
}),
},
express: {
...incomingConfig?.express,
middleware: [
...(incomingConfig?.express?.middleware || []),
getRouter(incomingConfig, options),
],
},
admin: {
...incomingConfig.admin,
webpack: webpackConfig => {
let newWebpackConfig = { ...webpackConfig }
if (typeof incomingConfig?.admin?.webpack === 'function')
newWebpackConfig = incomingConfig.admin.webpack(webpackConfig)
const webpackMock = path.resolve(__dirname, './utilities/webpackMock.js')
return {
...newWebpackConfig,
resolve: {
...newWebpackConfig.resolve,
alias: {
...(newWebpackConfig?.resolve?.alias || {}),
[path.resolve(__dirname, 'utilities/getRouter')]: webpackMock,
[path.resolve(__dirname, 'utilities/getMutation')]: webpackMock,
},
},
}
},
},
}
config.collections =
config?.collections?.map(collectionConfig => {
if (collections?.includes(collectionConfig.slug)) {
const cookiePrefix = getCookiePrefix(config.cookiePrefix || '', collectionConfig.slug)
const beforeReadHook: CollectionBeforeReadHook = async ({ req, doc }) => {
const whitelistUsersResponse =
typeof options.whitelistUsers === 'function'
? await options.whitelistUsers(req)
: false
if (!doc[options.passwordFieldName] || whitelistUsersResponse) return doc
const cookies = parseCookies(req)
const cookiePassword = cookies[`${cookiePrefix}-${doc.id}`]
if (cookiePassword === doc[options.passwordFieldName]) {
return doc
}
return {
id: doc.id,
[options.passwordProtectedFieldName]: true,
}
}
const collectionWithPasswordProtection: CollectionConfig = {
...collectionConfig,
hooks: {
...collectionConfig?.hooks,
beforeRead: [...(collectionConfig?.hooks?.beforeRead || []), beforeReadHook],
},
fields: [
...collectionConfig?.fields.map(field => {
const newField = { ...field }
newField.admin = {
...newField.admin,
condition: (data, siblingData) => {
const existingConditionResult = field?.admin?.condition
? field.admin.condition(data, siblingData)
: true
return data?.[options.passwordProtectedFieldName]
? false
: existingConditionResult
},
}
return newField
}),
{
name: options.passwordFieldName,
label: 'Password',
type: 'text',
admin: {
position: 'sidebar',
},
},
{
name: options.passwordProtectedFieldName,
type: 'checkbox',
hooks: {
beforeChange: [({ value }) => (value ? null : undefined)],
},
admin: {
disabled: true,
},
},
],
}
return collectionWithPasswordProtection
}
return collectionConfig
}) || []
return config
}
export default collectionPasswords

View File

@@ -0,0 +1,23 @@
import type { PayloadRequest } from 'payload/dist/express/types'
export type AllowUsers = (req: PayloadRequest) => Promise<boolean> | boolean
export interface PasswordProtectionConfig {
passwordFieldName?: string
passwordProtectedFieldName?: string
whitelistUsers?: AllowUsers
routePath?: string
mutationName?: string
expiration?: number
collections?: string[]
}
export interface PasswordProtectionOptions {
collections?: string[]
routePath: string
expiration: number
whitelistUsers: AllowUsers
passwordFieldName: string
passwordProtectedFieldName: string
mutationName: string
}

View File

@@ -0,0 +1,2 @@
export default (cookiePrefix: string, collectionSlug: string): string =>
`${cookiePrefix}-${collectionSlug}-password-`

View File

@@ -0,0 +1,63 @@
/* eslint-disable import/no-extraneous-dependencies */
import type { Response } from 'express'
import type { GraphQLFieldConfig } from 'graphql'
import type GraphQL from 'graphql'
import type { Payload } from 'payload'
import type { Config } from 'payload/config'
import type { PayloadRequest } from 'payload/dist/express/types'
import type { PasswordProtectionOptions } from '../types'
import operation from './operation'
interface Args {
collection: string
password: string
id: string
}
type MutationType = GraphQLFieldConfig<void, { req: PayloadRequest; res: Response }, Args>
const getMutation = (
GraphQLArg: typeof GraphQL,
payload: Payload,
config: Config,
options: PasswordProtectionOptions,
): MutationType => {
const { GraphQLBoolean, GraphQLString, GraphQLNonNull } = GraphQLArg
return {
type: GraphQLBoolean,
args: {
collection: {
type: new GraphQLNonNull(GraphQLString),
},
password: {
type: new GraphQLNonNull(GraphQLString),
},
id: {
type: new GraphQLNonNull(GraphQLString),
},
},
resolve: async (_, args, context) => {
const { collection, password, id } = args
try {
await operation({
config,
payload,
options,
collection,
password,
id,
res: context.res,
})
return true
} catch {
return false
}
},
}
}
export default getMutation

View File

@@ -0,0 +1,35 @@
import type { Router } from 'express'
import express from 'express'
import type { Config as PayloadConfig } from 'payload/config'
import type { PayloadRequest } from 'payload/dist/express/types'
import type { PasswordProtectionOptions } from '../types'
import operation from './operation'
export default (config: PayloadConfig, options: PasswordProtectionOptions): Router => {
const router = express.Router()
// TODO: the second argument of router.post() needs to be typed correctly
// @ts-expect-error
router.post(options.routePath || '/validate-password', async (req: PayloadRequest, res) => {
try {
const { body: { collection, password, id } = {}, payload } = req
await operation({
config,
payload,
options,
collection,
password,
id,
res,
})
res.status(200).send()
} catch (e: unknown) {
res.status(401).send()
}
})
return router
}

View File

@@ -0,0 +1,53 @@
import type { Response } from 'express'
import type { Payload } from 'payload'
import type { Config as PayloadConfig } from 'payload/config'
import APIError from 'payload/dist/errors/APIError'
import type { PasswordProtectionOptions } from '../types'
import getCookiePrefix from './getCookiePrefix'
interface Args {
config: PayloadConfig
payload: Payload
options: PasswordProtectionOptions
collection: string
password: string
id: string
res: Response
}
const validatePassword = async ({
config,
payload,
options,
collection,
password,
id,
res,
}: Args): Promise<void> => {
const doc = await payload.findByID({
id,
collection,
})
if (doc[options.passwordFieldName] === password) {
const expires = new Date()
expires.setSeconds(expires.getSeconds() + options.expiration || 7200)
const cookiePrefix = getCookiePrefix(config.cookiePrefix || '', collection)
const cookieOptions = {
path: '/',
httpOnly: true,
expires,
domain: undefined,
}
res.cookie(`${cookiePrefix}-${id}`, password, cookieOptions)
return
}
throw new APIError('The password provided is incorrect.', 400)
}
export default validatePassword

View File

@@ -0,0 +1,18 @@
import type { Request } from 'express'
function parseCookies(req: Request): { [key: string]: string } {
const list: { [key: string]: any } = {}
const rc = req.headers.cookie
if (rc) {
rc.split(';').forEach(cookie => {
const parts = cookie.split('=')
const keyToUse = parts.shift()?.trim() || ''
list[keyToUse] = decodeURI(parts.join('='))
})
}
return list
}
export default parseCookies

View File

@@ -0,0 +1 @@
module.exports = () => config => config

View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es5",
"outDir": "./dist",
"allowJs": true,
"module": "commonjs",
"sourceMap": true,
"jsx": "react",
"esModuleInterop": true,
"declaration": true,
"declarationDir": "./dist",
"skipLibCheck": true,
"strict": true,
},
"include": [
"src/**/*"
],
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff