fix(next): admin access control (#5887)
This commit is contained in:
@@ -25,6 +25,8 @@ type Args = {
|
|||||||
searchParams: { [key: string]: string | string[] | undefined }
|
searchParams: { [key: string]: string | string[] | undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authRoutes = ['/login', '/logout', '/create-first-user', '/forgot', '/reset', '/verify']
|
||||||
|
|
||||||
export const initPage = async ({
|
export const initPage = async ({
|
||||||
config: configPromise,
|
config: configPromise,
|
||||||
redirectUnauthenticatedUser = false,
|
redirectUnauthenticatedUser = false,
|
||||||
@@ -79,13 +81,19 @@ export const initPage = async ({
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeSegments = route.replace(payload.config.routes.admin, '').split('/').filter(Boolean)
|
const {
|
||||||
|
routes: { admin: adminRoute },
|
||||||
|
} = payload.config
|
||||||
|
|
||||||
|
const routeSegments = route.replace(adminRoute, '').split('/').filter(Boolean)
|
||||||
const [entityType, entitySlug, createOrID] = routeSegments
|
const [entityType, entitySlug, createOrID] = routeSegments
|
||||||
const collectionSlug = entityType === 'collections' ? entitySlug : undefined
|
const collectionSlug = entityType === 'collections' ? entitySlug : undefined
|
||||||
const globalSlug = entityType === 'globals' ? entitySlug : undefined
|
const globalSlug = entityType === 'globals' ? entitySlug : undefined
|
||||||
const docID = collectionSlug && createOrID !== 'create' ? createOrID : undefined
|
const docID = collectionSlug && createOrID !== 'create' ? createOrID : undefined
|
||||||
|
|
||||||
if (redirectUnauthenticatedUser && !user && route !== '/login') {
|
const isAuthRoute = authRoutes.some((r) => r === route.replace(adminRoute, ''))
|
||||||
|
|
||||||
|
if (redirectUnauthenticatedUser && !user && !isAuthRoute) {
|
||||||
if (searchParams && 'redirect' in searchParams) delete searchParams.redirect
|
if (searchParams && 'redirect' in searchParams) delete searchParams.redirect
|
||||||
|
|
||||||
const stringifiedSearchParams = Object.keys(searchParams ?? {}).length
|
const stringifiedSearchParams = Object.keys(searchParams ?? {}).length
|
||||||
@@ -95,6 +103,10 @@ export const initPage = async ({
|
|||||||
redirect(`${routes.admin}/login?redirect=${route + stringifiedSearchParams}`)
|
redirect(`${routes.admin}/login?redirect=${route + stringifiedSearchParams}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!permissions.canAccessAdmin && !isAuthRoute) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
let collectionConfig: SanitizedCollectionConfig
|
let collectionConfig: SanitizedCollectionConfig
|
||||||
let globalConfig: SanitizedGlobalConfig
|
let globalConfig: SanitizedGlobalConfig
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
firstArrayText,
|
firstArrayText,
|
||||||
hiddenAccessSlug,
|
hiddenAccessSlug,
|
||||||
hiddenFieldsSlug,
|
hiddenFieldsSlug,
|
||||||
|
noAdminAccessEmail,
|
||||||
readOnlySlug,
|
readOnlySlug,
|
||||||
relyOnRequestHeadersSlug,
|
relyOnRequestHeadersSlug,
|
||||||
restrictedSlug,
|
restrictedSlug,
|
||||||
@@ -41,6 +42,7 @@ const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => {
|
|||||||
export default buildConfigWithDefaults({
|
export default buildConfigWithDefaults({
|
||||||
admin: {
|
admin: {
|
||||||
user: 'users',
|
user: 'users',
|
||||||
|
autoLogin: false,
|
||||||
},
|
},
|
||||||
globals: [
|
globals: [
|
||||||
{
|
{
|
||||||
@@ -76,12 +78,17 @@ export default buildConfigWithDefaults({
|
|||||||
slug: 'users',
|
slug: 'users',
|
||||||
auth: true,
|
auth: true,
|
||||||
access: {
|
access: {
|
||||||
// admin: () => true,
|
// admin: () => true,
|
||||||
admin: async () =>
|
admin: async ({ req }) => {
|
||||||
new Promise((resolve) => {
|
if (req.user?.email === noAdminAccessEmail) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
// Simulate a request to an external service to determine access, i.e. another instance of Payload
|
// Simulate a request to an external service to determine access, i.e. another instance of Payload
|
||||||
setTimeout(resolve, 50, true) // set to 'true' or 'false' here to simulate the response
|
setTimeout(resolve, 50, true) // set to 'true' or 'false' here to simulate the response
|
||||||
}),
|
})
|
||||||
|
},
|
||||||
},
|
},
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
@@ -431,6 +438,14 @@ export default buildConfigWithDefaults({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await payload.create({
|
||||||
|
collection: 'users',
|
||||||
|
data: {
|
||||||
|
email: noAdminAccessEmail,
|
||||||
|
password: 'test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
await payload.create({
|
await payload.create({
|
||||||
collection: slug,
|
collection: slug,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Page } from '@playwright/test'
|
import type { Page } from '@playwright/test'
|
||||||
import type { Payload, TypeWithID } from 'payload/types'
|
import type { TypeWithID } from 'payload/types'
|
||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
import { devUser } from 'credentials.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { wait } from 'payload/utilities'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
ensureAutoLoginAndCompilationIsDone,
|
ensureAutoLoginAndCompilationIsDone,
|
||||||
exactText,
|
exactText,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
|
login,
|
||||||
openDocControls,
|
openDocControls,
|
||||||
openNav,
|
openNav,
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
@@ -22,6 +25,7 @@ import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
|||||||
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
|
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
|
||||||
import {
|
import {
|
||||||
docLevelAccessSlug,
|
docLevelAccessSlug,
|
||||||
|
noAdminAccessEmail,
|
||||||
readOnlySlug,
|
readOnlySlug,
|
||||||
restrictedSlug,
|
restrictedSlug,
|
||||||
restrictedVersionsSlug,
|
restrictedVersionsSlug,
|
||||||
@@ -61,7 +65,8 @@ describe('access control', () => {
|
|||||||
const context = await browser.newContext()
|
const context = await browser.newContext()
|
||||||
page = await context.newPage()
|
page = await context.newPage()
|
||||||
initPageConsoleErrorCatch(page)
|
initPageConsoleErrorCatch(page)
|
||||||
await ensureAutoLoginAndCompilationIsDone({ page, serverURL })
|
|
||||||
|
await login({ page, serverURL })
|
||||||
})
|
})
|
||||||
|
|
||||||
test('field without read access should not show', async () => {
|
test('field without read access should not show', async () => {
|
||||||
@@ -328,6 +333,28 @@ describe('access control', () => {
|
|||||||
// ensure user is allowed to edit this document
|
// ensure user is allowed to edit this document
|
||||||
await expect(documentDrawer2.locator('#field-name')).toBeEnabled()
|
await expect(documentDrawer2.locator('#field-name')).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('should completely block admin access', async () => {
|
||||||
|
const adminURL = `${serverURL}/admin`
|
||||||
|
await page.goto(adminURL)
|
||||||
|
await page.waitForURL(adminURL)
|
||||||
|
|
||||||
|
await expect(page.locator('.dashboard')).toBeVisible()
|
||||||
|
|
||||||
|
await page.goto(`${serverURL}/admin/logout`)
|
||||||
|
await page.waitForURL(`${serverURL}/admin/logout`)
|
||||||
|
|
||||||
|
await login({
|
||||||
|
page,
|
||||||
|
serverURL,
|
||||||
|
data: {
|
||||||
|
email: noAdminAccessEmail,
|
||||||
|
password: 'test',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(page.locator('.next-error-h1')).toBeVisible()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
// eslint-disable-next-line @typescript-eslint/require-await
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ export const docLevelAccessSlug = 'doc-level-access'
|
|||||||
export const hiddenFieldsSlug = 'hidden-fields'
|
export const hiddenFieldsSlug = 'hidden-fields'
|
||||||
|
|
||||||
export const hiddenAccessSlug = 'hidden-access'
|
export const hiddenAccessSlug = 'hidden-access'
|
||||||
|
|
||||||
|
export const noAdminAccessEmail = 'no-admin-access@payloadcms.com'
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ type FirstRegisterArgs = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LoginArgs = {
|
type LoginArgs = {
|
||||||
|
data?: {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
page: Page
|
page: Page
|
||||||
serverURL: string
|
serverURL: string
|
||||||
}
|
}
|
||||||
@@ -91,14 +95,24 @@ export async function firstRegister(args: FirstRegisterArgs): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function login(args: LoginArgs): Promise<void> {
|
export async function login(args: LoginArgs): Promise<void> {
|
||||||
const { page, serverURL } = args
|
const { page, serverURL, data = devUser } = args
|
||||||
|
|
||||||
await page.goto(`${serverURL}/admin`)
|
await page.goto(`${serverURL}/admin/login`)
|
||||||
await page.fill('#field-email', devUser.email)
|
await page.waitForURL(`${serverURL}/admin/login`)
|
||||||
await page.fill('#field-password', devUser.password)
|
await wait(500)
|
||||||
|
await page.fill('#field-email', data.email)
|
||||||
|
await page.fill('#field-password', data.password)
|
||||||
await wait(500)
|
await wait(500)
|
||||||
await page.click('[type=submit]')
|
await page.click('[type=submit]')
|
||||||
await page.waitForURL(`${serverURL}/admin`)
|
await page.waitForURL(`${serverURL}/admin`)
|
||||||
|
|
||||||
|
await expect(() => expect(page.url()).not.toContain(`/admin/login`)).toPass({
|
||||||
|
timeout: POLL_TOPASS_TIMEOUT,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(() => expect(page.url()).not.toContain(`/admin/create-first-user`)).toPass({
|
||||||
|
timeout: POLL_TOPASS_TIMEOUT,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
|
export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
FetchOptions,
|
FetchOptions,
|
||||||
FindArgs,
|
FindArgs,
|
||||||
GeneratedTypes,
|
GeneratedTypes,
|
||||||
|
LoginArgs,
|
||||||
UpdateArgs,
|
UpdateArgs,
|
||||||
UpdateGlobalArgs,
|
UpdateGlobalArgs,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
@@ -70,6 +71,17 @@ export class PayloadTestSDK<TGeneratedTypes extends GeneratedTypes<TGeneratedTyp
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
login = async <T extends keyof TGeneratedTypes['collections']>({
|
||||||
|
jwt,
|
||||||
|
...args
|
||||||
|
}: LoginArgs<TGeneratedTypes, T>) => {
|
||||||
|
return this.fetch<TGeneratedTypes['collections'][T]>({
|
||||||
|
operation: 'login',
|
||||||
|
args,
|
||||||
|
jwt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
sendEmail = async ({ jwt, ...args }: { jwt?: string } & SendMailOptions): Promise<unknown> => {
|
sendEmail = async ({ jwt, ...args }: { jwt?: string } & SendMailOptions): Promise<unknown> => {
|
||||||
return this.fetch({
|
return this.fetch({
|
||||||
operation: 'sendEmail',
|
operation: 'sendEmail',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type GeneratedTypes<T extends BaseTypes> = {
|
|||||||
export type FetchOptions = {
|
export type FetchOptions = {
|
||||||
args?: Record<string, unknown>
|
args?: Record<string, unknown>
|
||||||
jwt?: string
|
jwt?: string
|
||||||
operation: 'create' | 'delete' | 'find' | 'sendEmail' | 'update' | 'updateGlobal'
|
operation: 'create' | 'delete' | 'find' | 'login' | 'sendEmail' | 'update' | 'updateGlobal'
|
||||||
reduceJSON?: <R>(json: any) => R
|
reduceJSON?: <R>(json: any) => R
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +122,17 @@ export type FindArgs<
|
|||||||
where?: Where
|
where?: Where
|
||||||
} & BaseArgs
|
} & BaseArgs
|
||||||
|
|
||||||
|
export type LoginArgs<
|
||||||
|
TGeneratedTypes extends GeneratedTypes<TGeneratedTypes>,
|
||||||
|
TSlug extends keyof TGeneratedTypes['collections'],
|
||||||
|
> = {
|
||||||
|
collection: TSlug
|
||||||
|
data: {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
} & BaseArgs
|
||||||
|
|
||||||
export type DeleteArgs<
|
export type DeleteArgs<
|
||||||
TGeneratedTypes extends GeneratedTypes<TGeneratedTypes>,
|
TGeneratedTypes extends GeneratedTypes<TGeneratedTypes>,
|
||||||
TSlug extends keyof TGeneratedTypes['collections'],
|
TSlug extends keyof TGeneratedTypes['collections'],
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@payload-config": [
|
"@payload-config": [
|
||||||
"./test/_community/config.ts"
|
"./test/access-control/config.ts"
|
||||||
],
|
],
|
||||||
"@payloadcms/live-preview": [
|
"@payloadcms/live-preview": [
|
||||||
"./packages/live-preview/src"
|
"./packages/live-preview/src"
|
||||||
@@ -161,4 +161,4 @@
|
|||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
"scripts/**/*.ts"
|
"scripts/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user