├── .gitignore ├── .prettierrc.json ├── .swcrc ├── LICENSE ├── README.md ├── dev ├── .env.example ├── app │ ├── (payload) │ │ ├── admin │ │ │ ├── [[...segments]] │ │ │ │ ├── not-found.tsx │ │ │ │ └── page.tsx │ │ │ └── importMap.js │ │ ├── api │ │ │ ├── [...slug] │ │ │ │ └── route.ts │ │ │ ├── graphql-playground │ │ │ │ └── route.ts │ │ │ └── graphql │ │ │ │ └── route.ts │ │ ├── custom.scss │ │ └── layout.tsx │ └── my-route │ │ └── route.ts ├── collections │ └── User.ts ├── e2e.spec.ts ├── helpers │ ├── credentials.ts │ └── testEmailAdapter.ts ├── int.spec.ts ├── next-env.d.ts ├── next.config.mjs ├── payload-types.ts ├── payload.config.ts ├── seed.ts └── tsconfig.json ├── eslint.config.js ├── package.json ├── playwright.config.js ├── pnpm-lock.yaml ├── screenshots ├── masquerade-form.gif ├── masquerade-form.mp4 ├── masquerade-form.webm └── masquerade.png ├── src ├── actions │ └── Unmasquerade.tsx ├── endpoints │ ├── masqueradeEndpoint.ts │ └── unmasqueradeEndpoint.ts ├── index.ts └── ui │ ├── Masquerade.tsx │ ├── MasqueradeForm.tsx │ ├── NullField.tsx │ ├── SelectUser.tsx │ └── index.ts ├── tsconfig.json └── vitest.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | /.idea/* 10 | !/.idea/runConfigurations 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | .next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | /dist 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | 41 | .env 42 | 43 | /dev/media 44 | 45 | # Playwright 46 | /test-results/ 47 | /playwright-report/ 48 | /blob-report/ 49 | /playwright/.cache/ 50 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 100, 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/swcrc", 3 | "sourceMaps": true, 4 | "jsc": { 5 | "target": "esnext", 6 | "parser": { 7 | "syntax": "typescript", 8 | "tsx": true, 9 | "dts": true 10 | }, 11 | "transform": { 12 | "react": { 13 | "runtime": "automatic", 14 | "pragmaFrag": "React.Fragment", 15 | "throwIfNamespace": true, 16 | "development": false, 17 | "useBuiltins": true 18 | } 19 | } 20 | }, 21 | "module": { 22 | "type": "es6" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Payload 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Payload Plugin Masquerade 2 | 3 | [![npm version](https://badge.fury.io/js/payload-plugin-masquerade.svg)](https://www.npmjs.com/package/payload-plugin-masquerade) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | 6 | The Masquerade plugin allows administrators to switch users and surf the site as that user (no password required). Administrators can switch back to their own user account at any time. 7 | 8 | ![Masquerade Demo](https://raw.githubusercontent.com/manutepowa/payload-plugin-masquerade/main/screenshots/masquerade.png) 9 | 10 | ## Table of Contents 11 | 12 | - [Features](#features) 13 | - [Requirements](#requirements) 14 | - [Installation](#installation) 15 | - [Quick Start](#quick-start) 16 | - [Configuration Options](#configuration-options) 17 | - [Admin UI](#admin-ui) 18 | - [API Endpoints](#api-endpoints) 19 | - [Callbacks](#callbacks) 20 | - [Security & Best Practices](#security--best-practices) 21 | - [Development](#development) 22 | - [Troubleshooting](#troubleshooting) 23 | - [Known Issues](#known-issues) 24 | - [License](#license) 25 | - [Credits](#credits) 26 | 27 | ## Features 28 | 29 | - ✅ Compatible with Payload v3 (^3.44.0) 30 | - ✨ Zero external dependencies (only uses `uuid`) 31 | - ⚙ Highly customizable with callbacks 32 | - 🔒 Secure cookie-based authentication 33 | - 🎯 Admin UI integration with user selection 34 | - 📝 Audit trail support via callbacks 35 | 36 | ## Requirements 37 | 38 | - **Node.js**: >= 18 39 | - **Payload CMS**: >= 3.44.0 40 | 41 | ## Installation 42 | 43 | Choose your package manager: 44 | 45 | ```bash 46 | # pnpm 47 | pnpm add payload-plugin-masquerade 48 | 49 | # npm 50 | npm install payload-plugin-masquerade 51 | 52 | # yarn 53 | yarn add payload-plugin-masquerade 54 | ``` 55 | 56 | ## Quick Start 57 | 58 | Add the plugin to your Payload configuration: 59 | 60 | ```ts 61 | import { masqueradePlugin } from 'payload-plugin-masquerade' 62 | import { buildConfig } from 'payload' 63 | 64 | export default buildConfig({ 65 | // ... your other config 66 | plugins: [ 67 | masqueradePlugin({ 68 | // Optional: specify auth collection (defaults to 'users') 69 | authCollection: 'users', 70 | // Optional: enable/disable admin form (defaults to true) 71 | enableBlockForm: true, 72 | // Optional: enable/disable plugin (defaults to true) 73 | enabled: true, 74 | }), 75 | ], 76 | }) 77 | ``` 78 | 79 | ## Configuration Options 80 | 81 | | Option | Type | Default | Description | 82 | | ----------------- | ---------- | ----------- | ------------------------------------------------------ | 83 | | `authCollection` | `string` | `'users'` | Slug of the collection used for authentication | 84 | | `enableBlockForm` | `boolean` | `true` | Adds user selection block in Admin UI (beforeNavLinks) | 85 | | `enabled` | `boolean` | `true` | Enables/disables the entire plugin | 86 | | `onMasquerade` | `function` | `undefined` | Async callback called when starting masquerade | 87 | | `onUnmasquerade` | `function` | `undefined` | Async callback called when ending masquerade | 88 | 89 | ### Full Configuration Example 90 | 91 | ```ts 92 | import { masqueradePlugin } from 'payload-plugin-masquerade' 93 | 94 | export default buildConfig({ 95 | plugins: [ 96 | masqueradePlugin({ 97 | authCollection: 'users', 98 | enableBlockForm: true, 99 | enabled: true, 100 | onMasquerade: async ({ req, masqueradeUserId }) => { 101 | // req.user contains the original admin user 102 | console.log(`Admin ${req.user?.email} started masquerading as user ${masqueradeUserId}`) 103 | 104 | // Example: Log to audit collection 105 | await req.payload.create({ 106 | collection: 'auditLogs', 107 | data: { 108 | action: 'masquerade_start', 109 | adminId: req.user?.id, 110 | targetUserId: masqueradeUserId, 111 | timestamp: new Date(), 112 | }, 113 | }) 114 | }, 115 | onUnmasquerade: async ({ req, originalUserId }) => { 116 | // req.user contains the user we were masquerading as 117 | console.log(`Ending masquerade, returning to user ${originalUserId}`) 118 | 119 | // Example: Log audit trail 120 | await req.payload.create({ 121 | collection: 'auditLogs', 122 | data: { 123 | action: 'masquerade_end', 124 | adminId: originalUserId, 125 | masqueradeUserId: req.user?.id, 126 | timestamp: new Date(), 127 | }, 128 | }) 129 | }, 130 | }), 131 | ], 132 | }) 133 | ``` 134 | 135 | ## Admin UI 136 | 137 | The plugin adds a user selection form to the admin interface that appears before the navigation links: 138 | 139 | ![Masquerade Form](https://raw.githubusercontent.com/manutepowa/payload-plugin-masquerade/main/screenshots/masquerade-form.gif) 140 | 141 | To disable the admin form: 142 | 143 | ```ts 144 | masqueradePlugin({ 145 | enableBlockForm: false, 146 | }) 147 | ``` 148 | 149 | ## API Endpoints 150 | 151 | The plugin automatically adds these endpoints to your API: 152 | 153 | ### Start Masquerade 154 | 155 | ``` 156 | GET /api//:id/masquerade 157 | ``` 158 | 159 | **Behavior:** 160 | 161 | - Creates a JWT token for the target user 162 | - Sets Payload authentication cookie 163 | - Sets `masquerade` cookie with original user ID 164 | - Redirects to `/admin` 165 | 166 | **Example:** 167 | 168 | ```bash 169 | curl -i "http://localhost:3000/api/users/USER_ID/masquerade" 170 | ``` 171 | 172 | ### End Masquerade 173 | 174 | ``` 175 | GET /api//unmasquerade/:id 176 | ``` 177 | 178 | **Behavior:** 179 | 180 | - Restores authentication to original user (ID from route) 181 | - Clears `masquerade` cookie 182 | - Redirects to `/admin` 183 | 184 | **Example:** 185 | 186 | ```bash 187 | curl -i "http://localhost:3000/api/users/unmasquerade/ORIGINAL_USER_ID" 188 | ``` 189 | 190 | ## Callbacks 191 | 192 | ### onMasquerade 193 | 194 | Called when masquerade session starts: 195 | 196 | ```ts 197 | onMasquerade: async ({ req, masqueradeUserId }) => { 198 | // req: PayloadRequest (req.user is the original admin) 199 | // masqueradeUserId: ID of user being masqueraded 200 | } 201 | ``` 202 | 203 | ### onUnmasquerade 204 | 205 | Called when masquerade session ends: 206 | 207 | ```ts 208 | onUnmasquerade: async ({ req, originalUserId }) => { 209 | // req: PayloadRequest (req.user is the masqueraded user) 210 | // originalUserId: ID of the original admin user 211 | } 212 | ``` 213 | 214 | ## Security & Best Practices 215 | 216 | ⚠️ **Security Warning**: Masquerade functionality allows administrators to access user accounts without passwords. Follow these best practices: 217 | 218 | 1. **Restrict Access**: Only grant masquerade permissions to trusted administrators 219 | 2. **Audit Trail**: Use callbacks to log all masquerade activities: 220 | ```ts 221 | onMasquerade: async ({ req, masqueradeUserId }) => { 222 | await req.payload.create({ 223 | collection: 'securityLogs', 224 | data: { 225 | action: 'masquerade', 226 | adminId: req.user?.id, 227 | targetId: masqueradeUserId, 228 | ipAddress: req.ip, 229 | userAgent: req.headers['user-agent'], 230 | timestamp: new Date(), 231 | }, 232 | }) 233 | } 234 | ``` 235 | 3. **Monitor Usage**: Regularly review masquerade logs for suspicious activity 236 | 4. **Session Management**: Masquerade sessions use the same timeout as regular sessions 237 | 238 | ## Development 239 | 240 | To contribute or run the plugin locally: 241 | 242 | 1. **Clone the repository** 243 | 244 | ```bash 245 | git clone https://github.com/manutepowa/payload-plugin-masquerade.git 246 | cd payload-plugin-masquerade 247 | ``` 248 | 249 | 2. **Install dependencies** 250 | 251 | ```bash 252 | pnpm install 253 | ``` 254 | 255 | 3. **Set up development environment** 256 | 257 | ```bash 258 | cp dev/.env.example dev/.env 259 | # Edit dev/.env with your database configuration 260 | ``` 261 | 262 | 4. **Start development servers** 263 | 264 | ```bash 265 | pnpm dev 266 | ``` 267 | 268 | 5. **Visit the admin** 269 | Open http://localhost:3000/admin 270 | 271 | ### Testing 272 | 273 | ```bash 274 | # Run all tests 275 | pnpm test 276 | 277 | # Run integration tests only 278 | pnpm test:int 279 | 280 | # Run E2E tests only 281 | pnpm test:e2e 282 | ``` 283 | 284 | ## Troubleshooting 285 | 286 | ### "The collection with the slug '...' was not found" 287 | 288 | **Cause**: The `authCollection` option doesn't match any registered collection. 289 | 290 | **Solutions:** 291 | 292 | - Verify the collection slug matches exactly 293 | - Ensure the collection is registered in `buildConfig.collections` 294 | - Check that the plugin is loaded after the collection is defined 295 | 296 | ### Masquerade UI not appearing 297 | 298 | **Cause**: The `enableBlockForm` option might be disabled. 299 | 300 | **Solution:** 301 | 302 | ```ts 303 | masqueradePlugin({ 304 | enableBlockForm: true, // Make sure this is true 305 | }) 306 | ``` 307 | 308 | ### Authentication issues after masquerade 309 | 310 | **Cause**: Session conflicts or cookie issues. 311 | 312 | **Solutions:** 313 | 314 | - Clear browser cookies and try again 315 | - Check that your auth collection uses the same session configuration 316 | - Verify JWT signing key consistency 317 | 318 | ## License 319 | 320 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 321 | 322 | ## Credits 323 | 324 | - This package was inspired by the Drupal [Masquerade](https://www.drupal.org/project/masquerade) module 325 | - Maintained by [manutepowa](https://github.com/manutepowa) 326 | -------------------------------------------------------------------------------- /dev/.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URI=mongodb://127.0.0.1/payload-plugin-template 2 | PAYLOAD_SECRET=YOUR_SECRET_HERE 3 | -------------------------------------------------------------------------------- /dev/app/(payload)/admin/[[...segments]]/not-found.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, NotFoundPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const NotFound = ({ params, searchParams }: Args) => 23 | NotFoundPage({ config, importMap, params, searchParams }) 24 | 25 | export default NotFound 26 | -------------------------------------------------------------------------------- /dev/app/(payload)/admin/[[...segments]]/page.tsx: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import type { Metadata } from 'next' 4 | 5 | import config from '@payload-config' 6 | import { generatePageMetadata, RootPage } from '@payloadcms/next/views' 7 | 8 | import { importMap } from '../importMap' 9 | 10 | type Args = { 11 | params: Promise<{ 12 | segments: string[] 13 | }> 14 | searchParams: Promise<{ 15 | [key: string]: string | string[] 16 | }> 17 | } 18 | 19 | export const generateMetadata = ({ params, searchParams }: Args): Promise => 20 | generatePageMetadata({ config, params, searchParams }) 21 | 22 | const Page = ({ params, searchParams }: Args) => 23 | RootPage({ config, importMap, params, searchParams }) 24 | 25 | export default Page 26 | -------------------------------------------------------------------------------- /dev/app/(payload)/admin/importMap.js: -------------------------------------------------------------------------------- 1 | import { MasqueradeCell as MasqueradeCell_d4add19c08047af2d7c2e6635227b358 } from 'payload-plugin-masquerade/ui' 2 | import { NullField as NullField_d4add19c08047af2d7c2e6635227b358 } from 'payload-plugin-masquerade/ui' 3 | import { Unmasquerade as Unmasquerade_d4add19c08047af2d7c2e6635227b358 } from 'payload-plugin-masquerade/ui' 4 | import { MasqueradeForm as MasqueradeForm_d4add19c08047af2d7c2e6635227b358 } from 'payload-plugin-masquerade/ui' 5 | 6 | export const importMap = { 7 | "payload-plugin-masquerade/ui#MasqueradeCell": MasqueradeCell_d4add19c08047af2d7c2e6635227b358, 8 | "payload-plugin-masquerade/ui#NullField": NullField_d4add19c08047af2d7c2e6635227b358, 9 | "payload-plugin-masquerade/ui#Unmasquerade": Unmasquerade_d4add19c08047af2d7c2e6635227b358, 10 | "payload-plugin-masquerade/ui#MasqueradeForm": MasqueradeForm_d4add19c08047af2d7c2e6635227b358 11 | } 12 | -------------------------------------------------------------------------------- /dev/app/(payload)/api/[...slug]/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { 6 | REST_DELETE, 7 | REST_GET, 8 | REST_OPTIONS, 9 | REST_PATCH, 10 | REST_POST, 11 | REST_PUT, 12 | } from '@payloadcms/next/routes' 13 | 14 | export const GET = REST_GET(config) 15 | export const POST = REST_POST(config) 16 | export const DELETE = REST_DELETE(config) 17 | export const PATCH = REST_PATCH(config) 18 | export const PUT = REST_PUT(config) 19 | export const OPTIONS = REST_OPTIONS(config) 20 | -------------------------------------------------------------------------------- /dev/app/(payload)/api/graphql-playground/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import '@payloadcms/next/css' 5 | import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes' 6 | 7 | export const GET = GRAPHQL_PLAYGROUND_GET(config) 8 | -------------------------------------------------------------------------------- /dev/app/(payload)/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 2 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 3 | import config from '@payload-config' 4 | import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes' 5 | 6 | export const POST = GRAPHQL_POST(config) 7 | 8 | export const OPTIONS = REST_OPTIONS(config) 9 | -------------------------------------------------------------------------------- /dev/app/(payload)/custom.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manutepowa/payload-plugin-masquerade/bbd9fe2026b41cea2b8f3dbdc8dfc67645b013d1/dev/app/(payload)/custom.scss -------------------------------------------------------------------------------- /dev/app/(payload)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { ServerFunctionClient } from 'payload' 2 | 3 | import '@payloadcms/next/css' 4 | /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ 5 | /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ 6 | import config from '@payload-config' 7 | import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts' 8 | import React from 'react' 9 | 10 | import { importMap } from './admin/importMap' 11 | import './custom.scss' 12 | 13 | type Args = { 14 | children: React.ReactNode 15 | } 16 | 17 | const serverFunction: ServerFunctionClient = async function (args) { 18 | 'use server' 19 | return handleServerFunctions({ 20 | ...args, 21 | config, 22 | importMap, 23 | }) 24 | } 25 | 26 | const Layout = ({ children }: Args) => ( 27 | 28 | {children} 29 | 30 | ) 31 | 32 | export default Layout 33 | -------------------------------------------------------------------------------- /dev/app/my-route/route.ts: -------------------------------------------------------------------------------- 1 | import configPromise from '@payload-config' 2 | import { getPayload } from 'payload' 3 | 4 | export const GET = async (request: Request) => { 5 | const payload = await getPayload({ 6 | config: configPromise, 7 | }) 8 | 9 | return Response.json({ 10 | message: 'This is an example of a custom route.', 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /dev/collections/User.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig } from 'payload' 2 | 3 | export const Users: CollectionConfig = { 4 | slug: 'users', 5 | admin: { 6 | useAsTitle: 'email', 7 | }, 8 | auth: { 9 | useSessions: true, 10 | }, 11 | fields: [ 12 | { 13 | name: 'textField', 14 | type: 'text', 15 | }, 16 | { 17 | name: 'sidebarField', 18 | type: 'text', 19 | admin: { 20 | position: 'sidebar', 21 | }, 22 | }, 23 | ], 24 | } 25 | -------------------------------------------------------------------------------- /dev/e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test' 2 | 3 | // this is an example Playwright e2e test 4 | test('should render admin panel logo', async ({ page }) => { 5 | await page.goto('/admin') 6 | 7 | // login 8 | await page.fill('#field-email', 'dev@payloadcms.com') 9 | await page.fill('#field-password', 'test') 10 | await page.click('.form-submit button') 11 | 12 | // should show dashboard 13 | await expect(page).toHaveTitle(/Dashboard/) 14 | await expect(page.locator('.graphic-icon')).toBeVisible() 15 | }) 16 | -------------------------------------------------------------------------------- /dev/helpers/credentials.ts: -------------------------------------------------------------------------------- 1 | export const devUser = { 2 | email: 'dev@payloadcms.com', 3 | password: 'test', 4 | } 5 | -------------------------------------------------------------------------------- /dev/helpers/testEmailAdapter.ts: -------------------------------------------------------------------------------- 1 | import type { EmailAdapter, SendEmailOptions } from 'payload' 2 | 3 | /** 4 | * Logs all emails to stdout 5 | */ 6 | export const testEmailAdapter: EmailAdapter = ({ payload }) => ({ 7 | name: 'test-email-adapter', 8 | defaultFromAddress: 'dev@payloadcms.com', 9 | defaultFromName: 'Payload Test', 10 | sendEmail: async (message) => { 11 | const stringifiedTo = getStringifiedToAddress(message) 12 | const res = `Test email to: '${stringifiedTo}', Subject: '${message.subject}'` 13 | payload.logger.info({ content: message, msg: res }) 14 | return Promise.resolve() 15 | }, 16 | }) 17 | 18 | function getStringifiedToAddress(message: SendEmailOptions): string | undefined { 19 | let stringifiedTo: string | undefined 20 | 21 | if (typeof message.to === 'string') { 22 | stringifiedTo = message.to 23 | } else if (Array.isArray(message.to)) { 24 | stringifiedTo = message.to 25 | .map((to: { address: string } | string) => { 26 | if (typeof to === 'string') { 27 | return to 28 | } else if (to.address) { 29 | return to.address 30 | } 31 | return '' 32 | }) 33 | .join(', ') 34 | } else if (message.to?.address) { 35 | stringifiedTo = message.to.address 36 | } 37 | return stringifiedTo 38 | } 39 | -------------------------------------------------------------------------------- /dev/int.spec.ts: -------------------------------------------------------------------------------- 1 | import type { Payload } from 'payload' 2 | 3 | import config from '@payload-config' 4 | import { createPayloadRequest, getPayload } from 'payload' 5 | import { afterAll, beforeAll, describe, expect, test } from 'vitest' 6 | 7 | import { customEndpointHandler } from '../src/endpoints/customEndpointHandler.js' 8 | 9 | let payload: Payload 10 | 11 | afterAll(async () => { 12 | await payload.destroy() 13 | }) 14 | 15 | beforeAll(async () => { 16 | payload = await getPayload({ config }) 17 | }) 18 | 19 | describe('Plugin integration tests', () => { 20 | test('should query custom endpoint added by plugin', async () => { 21 | const request = new Request('http://localhost:3000/api/my-plugin-endpoint', { 22 | method: 'GET', 23 | }) 24 | 25 | const payloadRequest = await createPayloadRequest({ config, request }) 26 | const response = await customEndpointHandler(payloadRequest) 27 | expect(response.status).toBe(200) 28 | 29 | const data = await response.json() 30 | expect(data).toMatchObject({ 31 | message: 'Hello from custom endpoint', 32 | }) 33 | }) 34 | 35 | test('can create post with custom text field added by plugin', async () => { 36 | const post = await payload.create({ 37 | collection: 'posts', 38 | data: { 39 | addedByPlugin: 'added by plugin', 40 | }, 41 | }) 42 | expect(post.addedByPlugin).toBe('added by plugin') 43 | }) 44 | 45 | test('plugin creates and seeds plugin-collection', async () => { 46 | expect(payload.collections['plugin-collection']).toBeDefined() 47 | 48 | const { docs } = await payload.find({ collection: 'plugin-collection' }) 49 | 50 | expect(docs).toHaveLength(1) 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /dev/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /dev/next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withPayload } from '@payloadcms/next/withPayload' 2 | import { fileURLToPath } from 'url' 3 | import path from 'path' 4 | 5 | const dirname = path.dirname(fileURLToPath(import.meta.url)) 6 | 7 | /** @type {import('next').NextConfig} */ 8 | const nextConfig = { 9 | webpack: (webpackConfig) => { 10 | webpackConfig.resolve.extensionAlias = { 11 | '.cjs': ['.cts', '.cjs'], 12 | '.js': ['.ts', '.tsx', '.js', '.jsx'], 13 | '.mjs': ['.mts', '.mjs'], 14 | } 15 | 16 | return webpackConfig 17 | }, 18 | serverExternalPackages: ['mongodb-memory-server'], 19 | } 20 | 21 | export default withPayload(nextConfig, { devBundleServerPackages: false }) 22 | -------------------------------------------------------------------------------- /dev/payload-types.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | /** 4 | * This file was automatically generated by Payload. 5 | * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, 6 | * and re-run `payload generate:types` to regenerate this file. 7 | */ 8 | 9 | export interface Config { 10 | auth: { 11 | users: UserAuthOperations; 12 | }; 13 | collections: { 14 | posts: Post; 15 | media: Media; 16 | 'plugin-collection': PluginCollection; 17 | users: User; 18 | 'payload-locked-documents': PayloadLockedDocument; 19 | 'payload-preferences': PayloadPreference; 20 | 'payload-migrations': PayloadMigration; 21 | }; 22 | collectionsJoins: {}; 23 | collectionsSelect: { 24 | posts: PostsSelect | PostsSelect; 25 | media: MediaSelect | MediaSelect; 26 | 'plugin-collection': PluginCollectionSelect | PluginCollectionSelect; 27 | users: UsersSelect | UsersSelect; 28 | 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 29 | 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 30 | 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; 31 | }; 32 | db: { 33 | defaultIDType: string; 34 | }; 35 | globals: {}; 36 | globalsSelect: {}; 37 | locale: null; 38 | user: User & { 39 | collection: 'users'; 40 | }; 41 | jobs: { 42 | tasks: unknown; 43 | workflows: unknown; 44 | }; 45 | } 46 | export interface UserAuthOperations { 47 | forgotPassword: { 48 | email: string; 49 | password: string; 50 | }; 51 | login: { 52 | email: string; 53 | password: string; 54 | }; 55 | registerFirstUser: { 56 | email: string; 57 | password: string; 58 | }; 59 | unlock: { 60 | email: string; 61 | password: string; 62 | }; 63 | } 64 | /** 65 | * This interface was referenced by `Config`'s JSON-Schema 66 | * via the `definition` "posts". 67 | */ 68 | export interface Post { 69 | id: string; 70 | addedByPlugin?: string | null; 71 | updatedAt: string; 72 | createdAt: string; 73 | } 74 | /** 75 | * This interface was referenced by `Config`'s JSON-Schema 76 | * via the `definition` "media". 77 | */ 78 | export interface Media { 79 | id: string; 80 | updatedAt: string; 81 | createdAt: string; 82 | url?: string | null; 83 | thumbnailURL?: string | null; 84 | filename?: string | null; 85 | mimeType?: string | null; 86 | filesize?: number | null; 87 | width?: number | null; 88 | height?: number | null; 89 | focalX?: number | null; 90 | focalY?: number | null; 91 | } 92 | /** 93 | * This interface was referenced by `Config`'s JSON-Schema 94 | * via the `definition` "plugin-collection". 95 | */ 96 | export interface PluginCollection { 97 | id: string; 98 | updatedAt: string; 99 | createdAt: string; 100 | } 101 | /** 102 | * This interface was referenced by `Config`'s JSON-Schema 103 | * via the `definition` "users". 104 | */ 105 | export interface User { 106 | id: string; 107 | updatedAt: string; 108 | createdAt: string; 109 | email: string; 110 | resetPasswordToken?: string | null; 111 | resetPasswordExpiration?: string | null; 112 | salt?: string | null; 113 | hash?: string | null; 114 | loginAttempts?: number | null; 115 | lockUntil?: string | null; 116 | password?: string | null; 117 | } 118 | /** 119 | * This interface was referenced by `Config`'s JSON-Schema 120 | * via the `definition` "payload-locked-documents". 121 | */ 122 | export interface PayloadLockedDocument { 123 | id: string; 124 | document?: 125 | | ({ 126 | relationTo: 'posts'; 127 | value: string | Post; 128 | } | null) 129 | | ({ 130 | relationTo: 'media'; 131 | value: string | Media; 132 | } | null) 133 | | ({ 134 | relationTo: 'plugin-collection'; 135 | value: string | PluginCollection; 136 | } | null) 137 | | ({ 138 | relationTo: 'users'; 139 | value: string | User; 140 | } | null); 141 | globalSlug?: string | null; 142 | user: { 143 | relationTo: 'users'; 144 | value: string | User; 145 | }; 146 | updatedAt: string; 147 | createdAt: string; 148 | } 149 | /** 150 | * This interface was referenced by `Config`'s JSON-Schema 151 | * via the `definition` "payload-preferences". 152 | */ 153 | export interface PayloadPreference { 154 | id: string; 155 | user: { 156 | relationTo: 'users'; 157 | value: string | User; 158 | }; 159 | key?: string | null; 160 | value?: 161 | | { 162 | [k: string]: unknown; 163 | } 164 | | unknown[] 165 | | string 166 | | number 167 | | boolean 168 | | null; 169 | updatedAt: string; 170 | createdAt: string; 171 | } 172 | /** 173 | * This interface was referenced by `Config`'s JSON-Schema 174 | * via the `definition` "payload-migrations". 175 | */ 176 | export interface PayloadMigration { 177 | id: string; 178 | name?: string | null; 179 | batch?: number | null; 180 | updatedAt: string; 181 | createdAt: string; 182 | } 183 | /** 184 | * This interface was referenced by `Config`'s JSON-Schema 185 | * via the `definition` "posts_select". 186 | */ 187 | export interface PostsSelect { 188 | addedByPlugin?: T; 189 | updatedAt?: T; 190 | createdAt?: T; 191 | } 192 | /** 193 | * This interface was referenced by `Config`'s JSON-Schema 194 | * via the `definition` "media_select". 195 | */ 196 | export interface MediaSelect { 197 | updatedAt?: T; 198 | createdAt?: T; 199 | url?: T; 200 | thumbnailURL?: T; 201 | filename?: T; 202 | mimeType?: T; 203 | filesize?: T; 204 | width?: T; 205 | height?: T; 206 | focalX?: T; 207 | focalY?: T; 208 | } 209 | /** 210 | * This interface was referenced by `Config`'s JSON-Schema 211 | * via the `definition` "plugin-collection_select". 212 | */ 213 | export interface PluginCollectionSelect { 214 | id?: T; 215 | updatedAt?: T; 216 | createdAt?: T; 217 | } 218 | /** 219 | * This interface was referenced by `Config`'s JSON-Schema 220 | * via the `definition` "users_select". 221 | */ 222 | export interface UsersSelect { 223 | updatedAt?: T; 224 | createdAt?: T; 225 | email?: T; 226 | resetPasswordToken?: T; 227 | resetPasswordExpiration?: T; 228 | salt?: T; 229 | hash?: T; 230 | loginAttempts?: T; 231 | lockUntil?: T; 232 | } 233 | /** 234 | * This interface was referenced by `Config`'s JSON-Schema 235 | * via the `definition` "payload-locked-documents_select". 236 | */ 237 | export interface PayloadLockedDocumentsSelect { 238 | document?: T; 239 | globalSlug?: T; 240 | user?: T; 241 | updatedAt?: T; 242 | createdAt?: T; 243 | } 244 | /** 245 | * This interface was referenced by `Config`'s JSON-Schema 246 | * via the `definition` "payload-preferences_select". 247 | */ 248 | export interface PayloadPreferencesSelect { 249 | user?: T; 250 | key?: T; 251 | value?: T; 252 | updatedAt?: T; 253 | createdAt?: T; 254 | } 255 | /** 256 | * This interface was referenced by `Config`'s JSON-Schema 257 | * via the `definition` "payload-migrations_select". 258 | */ 259 | export interface PayloadMigrationsSelect { 260 | name?: T; 261 | batch?: T; 262 | updatedAt?: T; 263 | createdAt?: T; 264 | } 265 | /** 266 | * This interface was referenced by `Config`'s JSON-Schema 267 | * via the `definition` "auth". 268 | */ 269 | export interface Auth { 270 | [k: string]: unknown; 271 | } 272 | 273 | 274 | declare module 'payload' { 275 | export interface GeneratedTypes extends Config {} 276 | } -------------------------------------------------------------------------------- /dev/payload.config.ts: -------------------------------------------------------------------------------- 1 | import { mongooseAdapter } from '@payloadcms/db-mongodb' 2 | import { lexicalEditor } from '@payloadcms/richtext-lexical' 3 | import { MongoMemoryReplSet } from 'mongodb-memory-server' 4 | import path from 'path' 5 | import { buildConfig } from 'payload' 6 | import { masqueradePlugin } from 'payload-plugin-masquerade' 7 | import sharp from 'sharp' 8 | import { fileURLToPath } from 'url' 9 | 10 | import { Users } from './collections/User' 11 | import { testEmailAdapter } from './helpers/testEmailAdapter' 12 | import { seed } from './seed' 13 | 14 | const filename = fileURLToPath(import.meta.url) 15 | const dirname = path.dirname(filename) 16 | 17 | if (!process.env.ROOT_DIR) { 18 | process.env.ROOT_DIR = dirname 19 | } 20 | 21 | const buildConfigWithMemoryDB = async () => { 22 | if (process.env.NODE_ENV === 'test') { 23 | const memoryDB = await MongoMemoryReplSet.create({ 24 | replSet: { 25 | count: 3, 26 | dbName: 'payloadmemory', 27 | }, 28 | }) 29 | 30 | process.env.DATABASE_URI = `${memoryDB.getUri()}&retryWrites=true` 31 | } 32 | 33 | return buildConfig({ 34 | admin: { 35 | importMap: { 36 | baseDir: path.resolve(dirname), 37 | }, 38 | user: Users.slug, 39 | }, 40 | collections: [ 41 | Users, 42 | { 43 | slug: 'posts', 44 | fields: [], 45 | }, 46 | { 47 | slug: 'media', 48 | fields: [], 49 | upload: { 50 | staticDir: path.resolve(dirname, 'media'), 51 | }, 52 | }, 53 | ], 54 | db: mongooseAdapter({ 55 | ensureIndexes: true, 56 | url: process.env.DATABASE_URI || '', 57 | }), 58 | editor: lexicalEditor(), 59 | email: testEmailAdapter, 60 | onInit: async (payload) => { 61 | await seed(payload) 62 | }, 63 | plugins: [ 64 | masqueradePlugin({ 65 | authCollection: Users.slug, 66 | enableBlockForm: true, 67 | enabled: true, 68 | redirectPath: "/", 69 | onUnmasquerade: async ({ req, originalUserId }) => { 70 | console.log(Object.keys(req || {})) 71 | console.log(`You are: ${originalUserId || 'unknown'}`) 72 | console.log(`Your masquerade user is: ${req.user?.email || 'unknown'}`) 73 | }, 74 | onMasquerade: async ({ req, masqueradeUserId }) => { 75 | const { user: originalUser } = req 76 | // Custom logic when masquerading 77 | const { docs } = await req.payload.find({ 78 | collection: 'users', 79 | limit: 1, 80 | where: { 81 | id: { equals: masqueradeUserId }, 82 | }, 83 | }) 84 | 85 | console.log(`You are: ${originalUser?.email || 'unknown'}`) 86 | console.log(`You are masquerading as user: ${docs[0]?.email || 'unknown'}`) 87 | }, 88 | }), 89 | ], 90 | secret: process.env.PAYLOAD_SECRET || 'test-secret_key', 91 | sharp, 92 | typescript: { 93 | outputFile: path.resolve(dirname, 'payload-types.ts'), 94 | }, 95 | }) 96 | } 97 | 98 | export default buildConfigWithMemoryDB() 99 | -------------------------------------------------------------------------------- /dev/seed.ts: -------------------------------------------------------------------------------- 1 | import type { Payload } from 'payload' 2 | 3 | import { devUser } from './helpers/credentials' 4 | 5 | export const seed = async (payload: Payload) => { 6 | const { totalDocs } = await payload.count({ 7 | collection: 'users', 8 | where: { 9 | email: { 10 | equals: devUser.email, 11 | }, 12 | }, 13 | }) 14 | 15 | if (!totalDocs) { 16 | await payload.create({ 17 | collection: 'users', 18 | data: devUser, 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /dev/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "exclude": [], 4 | "include": [ 5 | "**/*.js", 6 | "**/*.jsx", 7 | "**/*.mjs", 8 | "**/*.cjs", 9 | "**/*.ts", 10 | "**/*.tsx", 11 | "../src/**/*.ts", 12 | "../src/**/*.tsx", 13 | "next.config.mjs", 14 | ".next/types/**/*.ts" 15 | ], 16 | "compilerOptions": { 17 | "baseUrl": "./", 18 | "paths": { 19 | "@payload-config": [ 20 | "./payload.config.ts" 21 | ], 22 | "payload-plugin-masquerade": [ 23 | "../src/index.ts" 24 | ], 25 | "payload-plugin-masquerade/ui": [ 26 | "../src/ui/index.ts" 27 | ] 28 | }, 29 | "noEmit": true, 30 | "emitDeclarationOnly": false 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | export const defaultESLintIgnores = [ 2 | '**/.temp', 3 | '**/.*', // ignore all dotfiles 4 | '**/.git', 5 | '**/.hg', 6 | '**/.pnp.*', 7 | '**/.svn', 8 | '**/playwright.config.ts', 9 | '**/vitest.config.js', 10 | '**/tsconfig.tsbuildinfo', 11 | '**/README.md', 12 | '**/eslint.config.js', 13 | '**/payload-types.ts', 14 | '**/dist/', 15 | '**/.yarn/', 16 | '**/build/', 17 | '**/node_modules/', 18 | '**/temp/', 19 | ] 20 | 21 | export default [ 22 | { 23 | rules: { 24 | 'no-restricted-exports': 'off', 25 | 'perfectionist/sort-objects': 'off', 26 | }, 27 | }, 28 | { 29 | languageOptions: { 30 | parserOptions: { 31 | sourceType: 'module', 32 | ecmaVersion: 'latest', 33 | projectService: { 34 | maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 40, 35 | allowDefaultProject: ['scripts/*.ts', '*.js', '*.mjs', '*.spec.ts', '*.d.ts'], 36 | }, 37 | // projectService: true, 38 | tsconfigRootDir: import.meta.dirname, 39 | }, 40 | }, 41 | }, 42 | ] 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "payload-plugin-masquerade", 3 | "version": "1.4.3", 4 | "private": false, 5 | "homepage:": "https://github.com/manutepowa/payload-plugin-masquerade", 6 | "repository": "https://github.com/manutepowa/payload-plugin-masquerade", 7 | "description": "Masquerade user for Payload CMS", 8 | "author": "manutepowa@gmail.com", 9 | "license": "MIT", 10 | "type": "module", 11 | "keywords": [ 12 | "payload", 13 | "cms", 14 | "plugin", 15 | "typescript", 16 | "react", 17 | "payload-plugin" 18 | ], 19 | "exports": { 20 | ".": { 21 | "import": "./src/index.ts", 22 | "default": "./src/index.ts", 23 | "types": "./src/index.ts" 24 | }, 25 | "./ui": { 26 | "import": "./src/ui/index.ts", 27 | "default": "./src/ui/index.ts", 28 | "types": "./src/ui/index.ts" 29 | } 30 | }, 31 | "main": "./src/index.ts", 32 | "types": "./src/index.ts", 33 | "files": [ 34 | "dist" 35 | ], 36 | "scripts": { 37 | "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc", 38 | "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", 39 | "build:types": "tsc --outDir dist --rootDir ./src", 40 | "clean": "rimraf {dist,*.tsbuildinfo}", 41 | "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", 42 | "dev": "next dev dev --turbo", 43 | "dev:generate-importmap": "pnpm dev:payload generate:importmap", 44 | "dev:generate-types": "pnpm dev:payload generate:types", 45 | "dev:payload": "cross-env PAYLOAD_CONFIG_PATH=./dev/payload.config.ts payload", 46 | "lint": "eslint", 47 | "lint:fix": "eslint ./src --fix", 48 | "prepublishOnly": "pnpm clean && pnpm build", 49 | "test": "pnpm test:int && pnpm test:e2e", 50 | "test:e2e": "playwright test", 51 | "test:int": "vitest" 52 | }, 53 | "peerDependencies": { 54 | "payload": "^3.44.0" 55 | }, 56 | "dependencies": { 57 | "uuid": "11.1.0" 58 | }, 59 | "devDependencies": { 60 | "@eslint/eslintrc": "^3.2.0", 61 | "@payloadcms/db-mongodb": "3.44.0", 62 | "@payloadcms/db-postgres": "3.44.0", 63 | "@payloadcms/db-sqlite": "3.44.0", 64 | "@payloadcms/next": "3.44.0", 65 | "@payloadcms/richtext-lexical": "3.44.0", 66 | "@payloadcms/ui": "3.44.0", 67 | "@playwright/test": "^1.52.0", 68 | "@swc-node/register": "1.10.10", 69 | "@swc/cli": "0.6.0", 70 | "@types/node": "^22.5.4", 71 | "@types/react": "19.1.6", 72 | "@types/react-dom": "19.1.6", 73 | "copyfiles": "2.4.1", 74 | "cross-env": "^7.0.3", 75 | "eslint": "^9.23.0", 76 | "eslint-config-next": "15.3.3", 77 | "graphql": "^16.8.1", 78 | "mongodb-memory-server": "10.1.4", 79 | "next": "15.3.3", 80 | "open": "^10.1.0", 81 | "payload": "3.44.0", 82 | "prettier": "^3.4.2", 83 | "qs-esm": "7.0.2", 84 | "react": "19.1.0", 85 | "react-dom": "19.1.0", 86 | "rimraf": "3.0.2", 87 | "sharp": "0.32.6", 88 | "sort-package-json": "^2.10.0", 89 | "typescript": "5.7.3", 90 | "vite-tsconfig-paths": "^5.1.4", 91 | "vitest": "^3.1.2" 92 | }, 93 | "publishConfig": { 94 | "exports": { 95 | ".": { 96 | "import": "./dist/index.js", 97 | "default": "./dist/index.js", 98 | "types": "./dist/index.d.ts" 99 | }, 100 | "./ui": { 101 | "import": "./dist/ui/index.js", 102 | "default": "./dist/ui/index.js", 103 | "types": "./dist/ui/index.d.ts" 104 | } 105 | }, 106 | "main": "./dist/index.js", 107 | "types": "./dist/index.d.ts" 108 | }, 109 | "pnpm": { 110 | "onlyBuiltDependencies": [ 111 | "sharp" 112 | ] 113 | }, 114 | "registry": "https://registry.npmjs.org/" 115 | } 116 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test' 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // import dotenv from 'dotenv'; 8 | // import path from 'path'; 9 | // dotenv.config({ path: path.resolve(__dirname, '.env') }); 10 | 11 | /** 12 | * See https://playwright.dev/docs/test-configuration. 13 | */ 14 | export default defineConfig({ 15 | testDir: './dev', 16 | testMatch: '**/e2e.spec.{ts,js}', 17 | /* Run tests in files in parallel */ 18 | fullyParallel: true, 19 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 20 | forbidOnly: !!process.env.CI, 21 | /* Retry on CI only */ 22 | retries: process.env.CI ? 2 : 0, 23 | /* Opt out of parallel tests on CI. */ 24 | workers: process.env.CI ? 1 : undefined, 25 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 26 | reporter: 'html', 27 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 28 | projects: [ 29 | { 30 | name: 'chromium', 31 | use: { ...devices['Desktop Chrome'] }, 32 | }, 33 | ], 34 | use: { 35 | /* Base URL to use in actions like `await page.goto('/')`. */ 36 | baseURL: 'http://localhost:3000', 37 | 38 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 39 | trace: 'on-first-retry', 40 | }, 41 | webServer: { 42 | command: 'pnpm dev', 43 | reuseExistingServer: true, 44 | url: 'http://localhost:3000/admin', 45 | }, 46 | }) 47 | -------------------------------------------------------------------------------- /screenshots/masquerade-form.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manutepowa/payload-plugin-masquerade/bbd9fe2026b41cea2b8f3dbdc8dfc67645b013d1/screenshots/masquerade-form.gif -------------------------------------------------------------------------------- /screenshots/masquerade-form.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manutepowa/payload-plugin-masquerade/bbd9fe2026b41cea2b8f3dbdc8dfc67645b013d1/screenshots/masquerade-form.mp4 -------------------------------------------------------------------------------- /screenshots/masquerade-form.webm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manutepowa/payload-plugin-masquerade/bbd9fe2026b41cea2b8f3dbdc8dfc67645b013d1/screenshots/masquerade-form.webm -------------------------------------------------------------------------------- /screenshots/masquerade.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/manutepowa/payload-plugin-masquerade/bbd9fe2026b41cea2b8f3dbdc8dfc67645b013d1/screenshots/masquerade.png -------------------------------------------------------------------------------- /src/actions/Unmasquerade.tsx: -------------------------------------------------------------------------------- 1 | import type { PayloadServerReactComponent, SanitizedConfig } from "payload" 2 | import type { CSSProperties } from "react" 3 | 4 | import { cookies } from "next/headers" 5 | 6 | 7 | const style: CSSProperties | undefined = { 8 | alignItems: 'center', 9 | borderBottom: '1px solid var(--theme-elevation-100)', 10 | display: 'flex', 11 | height: '30px', 12 | justifyContent: 'center', 13 | } 14 | 15 | export const Unmasquerade: PayloadServerReactComponent< 16 | SanitizedConfig['admin']['components']['header'][0] 17 | > = async ({ user }) => { 18 | const { email } = user 19 | const appCookies = await cookies() 20 | 21 | if (!appCookies.has("masquerade")) {return null} 22 | 23 | const userId = appCookies.get("masquerade") 24 | return ( 25 |
26 | You are masquerading with { email }{" "} 27 | 31 | Switch back 32 | 33 | 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/endpoints/masqueradeEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid' 2 | import { cookies } from 'next/headers' 3 | import { 4 | CollectionConfig, 5 | type Endpoint, 6 | generatePayloadCookie, 7 | getFieldsToSign, 8 | jwtSign, 9 | User, 10 | CollectionSlug, 11 | } from 'payload' 12 | import { PluginTypes } from 'src' 13 | 14 | export const masqueradeEndpoint = ( 15 | authCollectionSlug: string, 16 | onMasquerade: PluginTypes['onMasquerade'] | undefined, 17 | redirectPath?: string, 18 | ): Endpoint => ({ 19 | method: 'get', 20 | path: '/:id/masquerade', 21 | handler: async (req) => { 22 | const { payload, routeParams } = req 23 | 24 | const authCollection = payload.config.collections?.find( 25 | (collection) => collection.slug === authCollectionSlug, 26 | ) 27 | const isUseSessionsActive = authCollection?.auth?.useSessions === true 28 | 29 | const appCookies = await cookies() 30 | if (!routeParams?.id) { 31 | return new Response('No user ID provided', { status: 400 }) 32 | } 33 | 34 | const user = await payload.findByID({ 35 | collection: authCollectionSlug, 36 | id: routeParams.id as string, 37 | }) 38 | 39 | if (!user) { 40 | return new Response('User not found', { status: 404 }) 41 | } 42 | 43 | const fieldsToSignArgs: Parameters[0] = { 44 | collectionConfig: authCollection as CollectionConfig, 45 | email: user.email!, 46 | user: user as User, 47 | } 48 | if (isUseSessionsActive) { 49 | // Add session to user 50 | const newSessionID = uuid() 51 | const now = new Date() 52 | const tokenExpInMs = authCollection.auth.tokenExpiration * 1000 53 | const expiresAt = new Date(now.getTime() + tokenExpInMs) 54 | 55 | const session = { id: newSessionID, createdAt: now, expiresAt } 56 | 57 | if (!user.sessions?.length) { 58 | user.sessions = [session] 59 | } else { 60 | user.sessions.push(session) 61 | } 62 | 63 | await payload.update({ 64 | collection: authCollectionSlug as CollectionSlug, 65 | id: user.id, 66 | data: { sessions: user.sessions }, 67 | req, 68 | }) 69 | 70 | fieldsToSignArgs.sid = newSessionID 71 | } 72 | 73 | const fieldsToSign = getFieldsToSign(fieldsToSignArgs) 74 | 75 | const { token } = await jwtSign({ 76 | fieldsToSign, 77 | secret: payload.secret, 78 | tokenExpiration: authCollection?.auth.tokenExpiration!, 79 | }) 80 | 81 | const cookie = generatePayloadCookie({ 82 | collectionAuthConfig: authCollection?.auth!, 83 | cookiePrefix: payload.config.cookiePrefix, 84 | token, 85 | }) 86 | 87 | // Set masquerade cookie with allow unmasquerade 88 | appCookies.set('masquerade', req.user?.id.toString() as string) 89 | 90 | // Call onMasquerade callback if provided 91 | if (onMasquerade) { 92 | await onMasquerade({ req, masqueradeUserId: user.id }) 93 | } 94 | 95 | // success redirect 96 | return new Response(null, { 97 | headers: { 98 | 'Set-Cookie': cookie, 99 | Location: redirectPath ?? '/admin', 100 | }, 101 | status: 302, 102 | }) 103 | }, 104 | }) 105 | -------------------------------------------------------------------------------- /src/endpoints/unmasqueradeEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from 'next/headers' 2 | import { 3 | CollectionConfig, 4 | type Endpoint, 5 | generatePayloadCookie, 6 | getFieldsToSign, 7 | jwtSign, 8 | User, 9 | } from 'payload' 10 | import { PluginTypes } from 'src' 11 | 12 | export const unmasqueradeEndpoint = ( 13 | authCollectionSlug: string, 14 | onUnmasquerade: PluginTypes['onUnmasquerade'] | undefined, 15 | redirectPath?: string, 16 | ): Endpoint => ({ 17 | handler: async (req) => { 18 | const { payload, routeParams } = req 19 | const appCookies = await cookies() 20 | if (!routeParams?.id) { 21 | return new Response('No user ID provided', { status: 400 }) 22 | } 23 | 24 | const authCollection = payload.config.collections?.find( 25 | (collection) => collection.slug === authCollectionSlug, 26 | ) 27 | const isUseSessionsActive = authCollection?.auth?.useSessions === true 28 | 29 | const user = (await payload.findByID({ 30 | collection: authCollectionSlug, 31 | id: routeParams.id as string, 32 | })) as User 33 | 34 | if (!user) { 35 | return new Response('User not found', { status: 404 }) 36 | } 37 | 38 | const fieldsToSignArgs: Parameters[0] = { 39 | collectionConfig: authCollection as CollectionConfig, 40 | email: user.email!, 41 | user, 42 | } 43 | 44 | if (isUseSessionsActive) { 45 | fieldsToSignArgs.sid = user.sessions![0].id // Use the first session ID 46 | } 47 | 48 | const fieldsToSign = getFieldsToSign(fieldsToSignArgs) 49 | 50 | const { token } = await jwtSign({ 51 | fieldsToSign, 52 | secret: payload.secret, 53 | tokenExpiration: authCollection?.auth.tokenExpiration!, 54 | }) 55 | 56 | const cookie = generatePayloadCookie({ 57 | collectionAuthConfig: authCollection?.auth!, 58 | cookiePrefix: payload.config.cookiePrefix, 59 | token, 60 | }) 61 | 62 | // Set masquerade cookie with allow unmasquerade 63 | appCookies.delete('masquerade') 64 | 65 | // Call onUnmasquerade callback if provided 66 | if (onUnmasquerade) { 67 | await onUnmasquerade({ req, originalUserId: user.id }) 68 | } 69 | 70 | // success redirect 71 | return new Response(null, { 72 | headers: { 73 | 'Set-Cookie': cookie, 74 | Location: redirectPath ?? '/admin', 75 | }, 76 | status: 302, 77 | }) 78 | }, 79 | method: 'get', 80 | path: '/unmasquerade/:id', 81 | }) 82 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { CollectionConfig, Config, PayloadRequest, Plugin } from 'payload' 2 | 3 | import { cookies } from 'next/headers' 4 | 5 | import { masqueradeEndpoint } from './endpoints/masqueradeEndpoint' 6 | import { unmasqueradeEndpoint } from './endpoints/unmasqueradeEndpoint' 7 | 8 | export interface PluginTypes { 9 | /** 10 | * Slug of the collection where user information will be stored 11 | * @default "users" 12 | */ 13 | authCollection?: string 14 | /** 15 | * Enable block form 16 | * @default true 17 | */ 18 | enableBlockForm?: boolean 19 | /** 20 | * Enable or disable plugin 21 | * @default false 22 | */ 23 | enabled?: boolean 24 | /** 25 | * Optional callback that runs whenever an admin starts masquerading as another user. 26 | * 27 | * This function can be used to execute custom logic such as logging, notifications, 28 | * or permission checks. It receives the request object and the masqueraded user ID as arguments, 29 | * and can be asynchronous. 30 | * @req Original user is available in `req.user` 31 | */ 32 | onMasquerade?: ({ 33 | req, 34 | masqueradeUserId, 35 | }: { 36 | req: PayloadRequest 37 | masqueradeUserId: string | number 38 | }) => void | Promise 39 | /** 40 | * Optional callback that runs whenever an admin stops masquerading and returns to their original user. 41 | * 42 | * This function enables you to execute custom logic, such as logging, notifications, or cleanup actions. 43 | * It receives the request object and the original user ID as arguments and can be asynchronous. 44 | * @req Masquerade user is available in `req.user` 45 | */ 46 | onUnmasquerade?: ({ 47 | req, 48 | originalUserId, 49 | }: { 50 | req: PayloadRequest 51 | originalUserId: string | number 52 | }) => void | Promise 53 | /** 54 | * Path to redirect to after masquerade or unmasquerade actions 55 | * @default "/admin" 56 | */ 57 | redirectPath?: string 58 | } 59 | 60 | export const masqueradePlugin = 61 | (pluginOptions: PluginTypes): Plugin => 62 | (incomingConfig: Config): Config => { 63 | const config = { ...incomingConfig } 64 | 65 | if (pluginOptions.enabled === false) { 66 | return config 67 | } 68 | 69 | config.admin = { 70 | ...(config.admin || {}), 71 | components: { 72 | ...(config.admin?.components || {}), 73 | ...(pluginOptions.enableBlockForm !== false && { 74 | beforeNavLinks: [ 75 | ...(config.admin?.components?.beforeNavLinks || []), 76 | 'payload-plugin-masquerade/ui#MasqueradeForm', 77 | ], 78 | }), 79 | header: [ 80 | ...(config.admin?.components?.header || []), 81 | 'payload-plugin-masquerade/ui#Unmasquerade', 82 | ], 83 | }, 84 | } 85 | 86 | // Add authCollection field ui masquerade 87 | // Add authCollection endpoints to masquerade and unmasquerade 88 | 89 | const authCollectionSlug = pluginOptions.authCollection || 'users' 90 | const authCollection = config.collections?.find( 91 | (collection) => collection.slug === authCollectionSlug, 92 | ) 93 | 94 | if (!authCollection) { 95 | throw new Error(`The collection with the slug "${authCollectionSlug}" was not found.`) 96 | } 97 | 98 | const modifiedCollection: CollectionConfig = { 99 | ...authCollection, 100 | endpoints: [ 101 | ...(authCollection.endpoints || []), 102 | masqueradeEndpoint(authCollectionSlug, pluginOptions.onMasquerade, pluginOptions.redirectPath), 103 | unmasqueradeEndpoint(authCollectionSlug, pluginOptions.onUnmasquerade, pluginOptions.redirectPath), 104 | ], 105 | fields: [ 106 | ...(authCollection.fields || []), 107 | { 108 | name: 'masquerade', 109 | type: 'ui', 110 | admin: { 111 | components: { 112 | Cell: 'payload-plugin-masquerade/ui#MasqueradeCell', 113 | Field: 'payload-plugin-masquerade/ui#NullField', 114 | }, 115 | }, 116 | label: 'Masquerade', 117 | }, 118 | ], 119 | hooks: { 120 | ...(authCollection.hooks || []), 121 | afterLogout: [ 122 | ...(authCollection.hooks?.afterLogout || []), 123 | async () => { 124 | const cooks = await cookies() 125 | cooks.delete('masquerade') 126 | }, 127 | ], 128 | }, 129 | } 130 | 131 | config.collections = (config.collections || []).map((collection) => 132 | collection.slug === authCollectionSlug ? modifiedCollection : collection, 133 | ) 134 | 135 | return config 136 | } 137 | -------------------------------------------------------------------------------- /src/ui/Masquerade.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | import type { DefaultCellComponentProps } from "payload" 3 | 4 | import { useAuth } from "@payloadcms/ui" 5 | import React from "react" 6 | 7 | export const MasqueradeCell: React.FC = (props) => { 8 | const { rowData: { id: idUser } } = props 9 | const { user: loggedInUser } = useAuth() 10 | 11 | return ( 12 | <> 13 | {loggedInUser?.id !== idUser && ( 14 | Masquerade 15 | )} 16 | 17 | ) 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/ui/MasqueradeForm.tsx: -------------------------------------------------------------------------------- 1 | import type { PayloadServerReactComponent, SanitizedConfig } from "payload" 2 | 3 | import { cookies } from "next/headers" 4 | 5 | import { SelectUser } from "./SelectUser" 6 | import { Suspense } from "react" 7 | 8 | export const MasqueradeForm: PayloadServerReactComponent< 9 | SanitizedConfig['admin']['components']['beforeNavLinks'][0] 10 | > = async (props) => { 11 | const cc = await cookies() 12 | const isMasquerading = cc.get('masquerade')?.value 13 | 14 | const { payload, user: { id: meId } } = props 15 | const usersPromise = payload.find({ 16 | collection: 'users', 17 | limit: 0, 18 | }) 19 | 20 | if (isMasquerading) { 21 | return null // Don't show the form if already masquerading 22 | } 23 | 24 | return ( 25 | Loading...}> 26 | 27 | 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/NullField.tsx: -------------------------------------------------------------------------------- 1 | import type { UIField } from "payload"; 2 | import type React from "react" 3 | 4 | export const NullField: React.FC = () => null 5 | -------------------------------------------------------------------------------- /src/ui/SelectUser.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { SelectInput } from "@payloadcms/ui" 4 | import { useRouter } from "next/navigation" 5 | import { use } from "react" 6 | 7 | interface Props { 8 | meId: number | string 9 | usersPromise: Promise 10 | } 11 | 12 | export const SelectUser = ({meId, usersPromise}: Props) => { 13 | const router = useRouter() 14 | const { docs } = use(usersPromise) 15 | 16 | return ( 17 | { 20 | const selectedValue = (Array.isArray(option) ? option[0]?.value : option?.value) as string || '' 21 | // setValue(selectedValue) 22 | // /api/users/${idUser}/masquerade 23 | router.push(`/api/users/${selectedValue}/masquerade`) 24 | }} 25 | options={[ 26 | {label: 'Select a user', value: ''}, 27 | ...docs.map((user: any) => ({ 28 | label: user.email || user.id, // Assuming users have an 'email' field 29 | value: user.id, // Assuming users have an 'id' field 30 | })).filter((user: any) => user.value !== meId) // Exclude the current user, 31 | ]} 32 | path="" 33 | placeholder="Select a user to masquerade as" 34 | style={{ width: '100%', marginBottom: '1rem' }} 35 | /> 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { Unmasquerade } from '../actions/Unmasquerade' 2 | export { MasqueradeCell } from './Masquerade' 3 | export { MasqueradeForm } from './MasqueradeForm' 4 | export { NullField } from './NullField' 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "lib": [ 5 | "DOM", 6 | "DOM.Iterable", 7 | "ESNEXT" 8 | ], 9 | "rootDir": "./", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "strict": true, 13 | "esModuleInterop": true, 14 | "module": "esnext", 15 | "moduleResolution": "bundler", 16 | "resolveJsonModule": true, 17 | "isolatedModules": true, 18 | "jsx": "preserve", 19 | "incremental": true, 20 | "emitDeclarationOnly": true, 21 | "target": "ES2022", 22 | "composite": true, 23 | "plugins": [ 24 | { 25 | "name": "next" 26 | } 27 | ] 28 | }, 29 | "include": [ 30 | "./src/**/*.ts", 31 | "./src/**/*.tsx", 32 | "./dev/next-env.d.ts" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { loadEnv } from 'payload/node' 3 | import { fileURLToPath } from 'url' 4 | import tsconfigPaths from 'vite-tsconfig-paths' 5 | import { defineConfig } from 'vitest/config' 6 | 7 | const filename = fileURLToPath(import.meta.url) 8 | const dirname = path.dirname(filename) 9 | 10 | export default defineConfig(() => { 11 | loadEnv(path.resolve(dirname, './dev')) 12 | 13 | return { 14 | plugins: [ 15 | tsconfigPaths({ 16 | ignoreConfigErrors: true, 17 | }), 18 | ], 19 | test: { 20 | environment: 'node', 21 | hookTimeout: 30_000, 22 | testTimeout: 30_000, 23 | }, 24 | } 25 | }) 26 | --------------------------------------------------------------------------------