fix: client config context inheritance (#13790)
Redirecting from login to any non-auth collection crashes the page with the following error: ``` Cannot read properties of null (reading 'fields') ``` TL;DR: the page-level config context was threading stale methods from the root config provider. #### Background The client config is now gated behind authentication as of #13714, so it changes based on authentication status. If the root layout is mounted before authentication, it puts the unauthenticated client config into state for the entire app to consume. On login, the root layout does not re-render, so the page itself needs to generate a fresh client config and sync it up. This leads to race conditions, however, where if the login page included a `?redirect=` param, the redirect would take place _before_ the page-level client config could sync to the layout, and ultimately crash the page. This was addressed in #13786. While this fixed redirects to the "users" collection, this collection is _already_ included in the client config (soon to be omitted by #13785). So if you redirect to any other collection, the above error occurs. #### Problem The page-level config context is only overriding the `config` property, keeping stale methods from the root config provider. This means calling `getEntityConfig` during this moment in the time would reference the stale config, although `config` itself would be fresh. #### Solution Wrap the page with an entirely new context provider. Do not thread inherited methods from the root provider, this way all new methods get instantiated using the fresh config. --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1211332845301596
This commit is contained in:
@@ -10,7 +10,7 @@ import type {
|
|||||||
SanitizedGlobalConfig,
|
SanitizedGlobalConfig,
|
||||||
} from 'payload'
|
} from 'payload'
|
||||||
|
|
||||||
import { RootPageConfigProvider } from '@payloadcms/ui'
|
import { PageConfigProvider } from '@payloadcms/ui'
|
||||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||||
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
|
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
|
||||||
import { notFound, redirect } from 'next/navigation.js'
|
import { notFound, redirect } from 'next/navigation.js'
|
||||||
@@ -300,7 +300,7 @@ export const RootPage = async ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RootPageConfigProvider config={clientConfig}>
|
<PageConfigProvider config={clientConfig}>
|
||||||
{!templateType && <React.Fragment>{RenderedView}</React.Fragment>}
|
{!templateType && <React.Fragment>{RenderedView}</React.Fragment>}
|
||||||
{templateType === 'minimal' && (
|
{templateType === 'minimal' && (
|
||||||
<MinimalTemplate className={templateClassName}>{RenderedView}</MinimalTemplate>
|
<MinimalTemplate className={templateClassName}>{RenderedView}</MinimalTemplate>
|
||||||
@@ -331,6 +331,6 @@ export const RootPage = async ({
|
|||||||
{RenderedView}
|
{RenderedView}
|
||||||
</DefaultTemplate>
|
</DefaultTemplate>
|
||||||
)}
|
)}
|
||||||
</RootPageConfigProvider>
|
</PageConfigProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export type UnauthenticatedClientConfig = {
|
|||||||
globals: []
|
globals: []
|
||||||
routes: ClientConfig['routes']
|
routes: ClientConfig['routes']
|
||||||
serverURL: ClientConfig['serverURL']
|
serverURL: ClientConfig['serverURL']
|
||||||
unauthenticated: ClientConfig['unauthenticated']
|
unauthenticated: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serverOnlyAdminConfigProperties: readonly Partial<ServerOnlyRootAdminProperties>[] = []
|
export const serverOnlyAdminConfigProperties: readonly Partial<ServerOnlyRootAdminProperties>[] = []
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export {
|
|||||||
RouteTransitionProvider,
|
RouteTransitionProvider,
|
||||||
useRouteTransition,
|
useRouteTransition,
|
||||||
} from '../../providers/RouteTransition/index.js'
|
} from '../../providers/RouteTransition/index.js'
|
||||||
export { ConfigProvider, RootPageConfigProvider, useConfig } from '../../providers/Config/index.js'
|
export { ConfigProvider, PageConfigProvider, useConfig } from '../../providers/Config/index.js'
|
||||||
export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js'
|
export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js'
|
||||||
export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js'
|
||||||
export { useDocumentTitle } from '../../providers/DocumentTitle/index.js'
|
export { useDocumentTitle } from '../../providers/DocumentTitle/index.js'
|
||||||
|
|||||||
@@ -100,41 +100,42 @@ export const ConfigProvider: React.FC<{
|
|||||||
export const useConfig = (): ClientConfigContext => use(RootConfigContext)
|
export const useConfig = (): ClientConfigContext => use(RootConfigContext)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This provider shadows the ConfigProvider on the _page_ level, allowing us to
|
* This provider shadows the `ConfigProvider` on the _page_ level, allowing us to
|
||||||
* update the config when needed, e.g. after authentication.
|
* update the config when needed, e.g. after authentication.
|
||||||
* The layout ConfigProvider is not updated on page navigation / authentication,
|
* The layout `ConfigProvider` is not updated on page navigation / authentication,
|
||||||
* as the layout does not re-render in those cases.
|
* as the layout does not re-render in those cases.
|
||||||
*
|
*
|
||||||
* If the config here has the same reference as the config from the layout, we
|
* If the config here has the same reference as the config from the layout, we
|
||||||
* simply reuse the context from the layout to avoid unnecessary re-renders.
|
* simply reuse the context from the layout to avoid unnecessary re-renders.
|
||||||
|
*
|
||||||
|
* @experimental This component is experimental and may change or be removed in future releases. Use at your own discretion.
|
||||||
*/
|
*/
|
||||||
export const RootPageConfigProvider: React.FC<{
|
export const PageConfigProvider: React.FC<{
|
||||||
readonly children: React.ReactNode
|
readonly children: React.ReactNode
|
||||||
readonly config: ClientConfig
|
readonly config: ClientConfig
|
||||||
}> = ({ children, config: configFromProps }) => {
|
}> = ({ children, config: configFromProps }) => {
|
||||||
const rootLayoutConfig = useConfig()
|
const { config: rootConfig, setConfig: setRootConfig } = useConfig()
|
||||||
const { config, getEntityConfig, setConfig } = rootLayoutConfig
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This useEffect is required in order for the _page_ to be able to refresh the client config,
|
* This `useEffect` is required in order for the _page_ to be able to refresh the client config,
|
||||||
* which may have been cached on the _layout_ level, where the ConfigProvider is managed.
|
* which may have been cached on the _layout_ level, where the `ConfigProvider` is managed.
|
||||||
* Since the layout does not re-render on page navigation / authentication, we need to manually
|
* Since the layout does not re-render on page navigation / authentication, we need to manually
|
||||||
* update the config, as the user may have been authenticated in the process, which affects the client config.
|
* update the config, as the user may have been authenticated in the process, which affects the client config.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConfig(configFromProps)
|
setRootConfig(configFromProps)
|
||||||
}, [configFromProps, setConfig])
|
}, [configFromProps, setRootConfig])
|
||||||
|
|
||||||
if (config !== configFromProps && config.unauthenticated !== configFromProps.unauthenticated) {
|
// If this component receives a different config than what is in context from the layout, it is stale.
|
||||||
// Between the unauthenticated config becoming authenticated (or the other way around) and the useEffect
|
// While stale, we instantiate a new context provider that provides the new config until the root context is updated.
|
||||||
// running, the config will be stale. In order to avoid having the wrong config in the context in that
|
// Unfortunately, referential equality alone does not work bc the reference is lost during server/client serialization,
|
||||||
// brief moment, we shadow the context here on the _page_ level and provide the updated config immediately.
|
// so we need to also compare the `unauthenticated` property.
|
||||||
return (
|
if (
|
||||||
<RootConfigContext value={{ config: configFromProps, getEntityConfig, setConfig }}>
|
rootConfig !== configFromProps &&
|
||||||
{children}
|
rootConfig.unauthenticated !== configFromProps.unauthenticated
|
||||||
</RootConfigContext>
|
) {
|
||||||
)
|
return <ConfigProvider config={configFromProps}>{children}</ConfigProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <RootConfigContext value={rootLayoutConfig}>{children}</RootConfigContext>
|
return children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ import {
|
|||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
exactText,
|
exactText,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
login,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
|
import { login } from '../helpers/e2e/auth/login.js'
|
||||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { fileURLToPath } from 'url'
|
|||||||
import {
|
import {
|
||||||
ensureCompilationIsDone,
|
ensureCompilationIsDone,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
login,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
// throttleTest,
|
// throttleTest,
|
||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
|
import { login } from '../helpers/e2e/auth/login.js'
|
||||||
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
|
||||||
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { BrowserContext, Page } from '@playwright/test'
|
|||||||
|
|
||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import { devUser } from 'credentials.js'
|
import { devUser } from 'credentials.js'
|
||||||
|
import { login } from 'helpers/e2e/auth/login.js'
|
||||||
|
import { logout } from 'helpers/e2e/auth/logout.js'
|
||||||
import { openNav } from 'helpers/e2e/toggleNav.js'
|
import { openNav } from 'helpers/e2e/toggleNav.js'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
@@ -15,7 +17,6 @@ import {
|
|||||||
exactText,
|
exactText,
|
||||||
getRoutes,
|
getRoutes,
|
||||||
initPageConsoleErrorCatch,
|
initPageConsoleErrorCatch,
|
||||||
login,
|
|
||||||
saveDocAndAssert,
|
saveDocAndAssert,
|
||||||
} from '../helpers.js'
|
} from '../helpers.js'
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
@@ -388,21 +389,24 @@ describe('Auth', () => {
|
|||||||
await saveDocAndAssert(page)
|
await saveDocAndAssert(page)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('ensure login page with redirect to users document redirects properly after login, without client error', async () => {
|
test('ensure `?redirect=` param is injected into the URL and handled properly after login', async () => {
|
||||||
await page.goto(url.admin)
|
|
||||||
|
|
||||||
await page.goto(`${serverURL}/admin/logout`)
|
|
||||||
|
|
||||||
await expect(page.locator('.login')).toBeVisible()
|
|
||||||
|
|
||||||
const users = await payload.find({
|
const users = await payload.find({
|
||||||
collection: slug,
|
collection: slug,
|
||||||
limit: 1,
|
limit: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const userDocumentRoute = `${serverURL}/admin/collections/users/${users?.docs?.[0]?.id}`
|
const userDocumentRoute = `${serverURL}/admin/collections/users/${users?.docs?.[0]?.id}`
|
||||||
|
|
||||||
|
await logout(page, serverURL)
|
||||||
|
|
||||||
|
// This will send the user back to the login page with a `?redirect=` param
|
||||||
await page.goto(userDocumentRoute)
|
await page.goto(userDocumentRoute)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
|
||||||
|
.toContain('/admin/login?redirect=')
|
||||||
|
|
||||||
|
// Important: do not use the login helper here, as this may clear the redirect param
|
||||||
await expect(page.locator('#field-email')).toBeVisible()
|
await expect(page.locator('#field-email')).toBeVisible()
|
||||||
await expect(page.locator('#field-password')).toBeVisible()
|
await expect(page.locator('#field-password')).toBeVisible()
|
||||||
|
|
||||||
@@ -414,8 +418,37 @@ describe('Auth', () => {
|
|||||||
.toBe(userDocumentRoute)
|
.toBe(userDocumentRoute)
|
||||||
|
|
||||||
// Previously, this would crash the page with a "Cannot read properties of undefined (reading 'match')" error
|
// Previously, this would crash the page with a "Cannot read properties of undefined (reading 'match')" error
|
||||||
|
|
||||||
await expect(page.locator('#field-roles')).toBeVisible()
|
await expect(page.locator('#field-roles')).toBeVisible()
|
||||||
|
|
||||||
|
// Now do this again, only with a page that is not in the user's collection
|
||||||
|
const notInUserCollection = await payload.create({
|
||||||
|
collection: 'relationsCollection',
|
||||||
|
data: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
await logout(page, serverURL)
|
||||||
|
|
||||||
|
await page.goto(
|
||||||
|
`${serverURL}/admin/collections/relationsCollection/${notInUserCollection.id}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
|
||||||
|
.toContain('/admin/login?redirect=')
|
||||||
|
|
||||||
|
// Important: do not use the login helper here, as this may clear the redirect param
|
||||||
|
await expect(page.locator('#field-email')).toBeVisible()
|
||||||
|
await expect(page.locator('#field-password')).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator('.form-submit > button').click()
|
||||||
|
|
||||||
|
// Expect to be redirected to the correct page
|
||||||
|
await expect
|
||||||
|
.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT })
|
||||||
|
.toBe(`${serverURL}/admin/collections/relationsCollection/${notInUserCollection.id}`)
|
||||||
|
|
||||||
|
// Previously, this would crash the page with a "Cannot read properties of null (reading 'fields')" error
|
||||||
|
await expect(page.locator('#field-rel')).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
117
test/helpers.ts
117
test/helpers.ts
@@ -18,18 +18,7 @@ import { devUser } from './credentials.js'
|
|||||||
import { openNav } from './helpers/e2e/toggleNav.js'
|
import { openNav } from './helpers/e2e/toggleNav.js'
|
||||||
import { POLL_TOPASS_TIMEOUT } from './playwright.config.js'
|
import { POLL_TOPASS_TIMEOUT } from './playwright.config.js'
|
||||||
|
|
||||||
type AdminRoutes = NonNullable<Config['admin']>['routes']
|
export type AdminRoutes = NonNullable<Config['admin']>['routes']
|
||||||
|
|
||||||
type LoginArgs = {
|
|
||||||
customAdminRoutes?: AdminRoutes
|
|
||||||
customRoutes?: Config['routes']
|
|
||||||
data?: {
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
page: Page
|
|
||||||
serverURL: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const random = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
|
const random = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
|
|
||||||
@@ -177,110 +166,6 @@ export async function throttleTest({
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs a user in by navigating via click-ops instead of using page.goto()
|
|
||||||
*/
|
|
||||||
export async function loginClientSide(args: LoginArgs): Promise<void> {
|
|
||||||
const { customAdminRoutes, customRoutes, data = devUser, page, serverURL } = args
|
|
||||||
const {
|
|
||||||
routes: { admin: incomingAdminRoute } = {},
|
|
||||||
admin: { routes: { login: incomingLoginRoute, createFirstUser } = {} },
|
|
||||||
} = getRoutes({ customAdminRoutes, customRoutes })
|
|
||||||
|
|
||||||
const adminRoute = formatAdminURL({ serverURL, adminRoute: incomingAdminRoute, path: '' })
|
|
||||||
const loginRoute = formatAdminURL({
|
|
||||||
serverURL,
|
|
||||||
adminRoute: incomingAdminRoute,
|
|
||||||
path: incomingLoginRoute,
|
|
||||||
})
|
|
||||||
const createFirstUserRoute = formatAdminURL({
|
|
||||||
serverURL,
|
|
||||||
adminRoute: incomingAdminRoute,
|
|
||||||
path: createFirstUser,
|
|
||||||
})
|
|
||||||
|
|
||||||
if ((await page.locator('#nav-toggler').count()) > 0) {
|
|
||||||
// a user is already logged in - log them out
|
|
||||||
await openNav(page)
|
|
||||||
await expect(page.locator('.nav__controls [aria-label="Log out"]')).toBeVisible()
|
|
||||||
await page.locator('.nav__controls [aria-label="Log out"]').click()
|
|
||||||
|
|
||||||
if (await page.locator('dialog#leave-without-saving').isVisible()) {
|
|
||||||
await page.locator('dialog#leave-without-saving #confirm-action').click()
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForURL(loginRoute)
|
|
||||||
}
|
|
||||||
|
|
||||||
await wait(500)
|
|
||||||
await page.fill('#field-email', data.email)
|
|
||||||
await page.fill('#field-password', data.password)
|
|
||||||
await wait(500)
|
|
||||||
await page.click('[type=submit]')
|
|
||||||
|
|
||||||
await expect(page.locator('.step-nav__home')).toBeVisible()
|
|
||||||
if ((await page.locator('a.step-nav__home').count()) > 0) {
|
|
||||||
await page.locator('a.step-nav__home').click()
|
|
||||||
}
|
|
||||||
|
|
||||||
await page.waitForURL(adminRoute)
|
|
||||||
|
|
||||||
await expect(() => expect(page.url()).not.toContain(loginRoute)).toPass({
|
|
||||||
timeout: POLL_TOPASS_TIMEOUT,
|
|
||||||
})
|
|
||||||
await expect(() => expect(page.url()).not.toContain(createFirstUserRoute)).toPass({
|
|
||||||
timeout: POLL_TOPASS_TIMEOUT,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(args: LoginArgs): Promise<void> {
|
|
||||||
const { customAdminRoutes, customRoutes, data = devUser, page, serverURL } = args
|
|
||||||
|
|
||||||
const {
|
|
||||||
admin: {
|
|
||||||
routes: { createFirstUser, login: incomingLoginRoute, logout: incomingLogoutRoute } = {},
|
|
||||||
},
|
|
||||||
routes: { admin: incomingAdminRoute } = {},
|
|
||||||
} = getRoutes({ customAdminRoutes, customRoutes })
|
|
||||||
|
|
||||||
const logoutRoute = formatAdminURL({
|
|
||||||
serverURL,
|
|
||||||
adminRoute: incomingAdminRoute,
|
|
||||||
path: incomingLogoutRoute,
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto(logoutRoute)
|
|
||||||
await wait(500)
|
|
||||||
|
|
||||||
const adminRoute = formatAdminURL({ serverURL, adminRoute: incomingAdminRoute, path: '' })
|
|
||||||
const loginRoute = formatAdminURL({
|
|
||||||
serverURL,
|
|
||||||
adminRoute: incomingAdminRoute,
|
|
||||||
path: incomingLoginRoute,
|
|
||||||
})
|
|
||||||
const createFirstUserRoute = formatAdminURL({
|
|
||||||
serverURL,
|
|
||||||
adminRoute: incomingAdminRoute,
|
|
||||||
path: createFirstUser,
|
|
||||||
})
|
|
||||||
|
|
||||||
await page.goto(loginRoute)
|
|
||||||
await wait(500)
|
|
||||||
await page.fill('#field-email', data.email)
|
|
||||||
await page.fill('#field-password', data.password)
|
|
||||||
await wait(500)
|
|
||||||
await page.click('[type=submit]')
|
|
||||||
await page.waitForURL(adminRoute)
|
|
||||||
|
|
||||||
await expect(() => expect(page.url()).not.toContain(loginRoute)).toPass({
|
|
||||||
timeout: POLL_TOPASS_TIMEOUT,
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect(() => expect(page.url()).not.toContain(createFirstUserRoute)).toPass({
|
|
||||||
timeout: POLL_TOPASS_TIMEOUT,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
|
export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
|
||||||
const ua = page.evaluate(() => navigator.userAgent)
|
const ua = page.evaluate(() => navigator.userAgent)
|
||||||
const isMac = (await ua).includes('Mac OS X')
|
const isMac = (await ua).includes('Mac OS X')
|
||||||
|
|||||||
126
test/helpers/e2e/auth/login.ts
Normal file
126
test/helpers/e2e/auth/login.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { AdminRoutes } from 'helpers.js'
|
||||||
|
import type { Config } from 'payload'
|
||||||
|
import type { Page } from 'playwright/test'
|
||||||
|
|
||||||
|
import { devUser } from 'credentials.js'
|
||||||
|
import { getRoutes } from 'helpers.js'
|
||||||
|
import { formatAdminURL, wait } from 'payload/shared'
|
||||||
|
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
|
||||||
|
import { expect } from 'playwright/test'
|
||||||
|
|
||||||
|
import { openNav } from '../toggleNav.js'
|
||||||
|
|
||||||
|
type LoginArgs = {
|
||||||
|
customAdminRoutes?: AdminRoutes
|
||||||
|
customRoutes?: Config['routes']
|
||||||
|
data?: {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
page: Page
|
||||||
|
serverURL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(args: LoginArgs): Promise<void> {
|
||||||
|
const { customAdminRoutes, customRoutes, data = devUser, page, serverURL } = args
|
||||||
|
|
||||||
|
const {
|
||||||
|
admin: {
|
||||||
|
routes: { createFirstUser, login: incomingLoginRoute, logout: incomingLogoutRoute } = {},
|
||||||
|
},
|
||||||
|
routes: { admin: incomingAdminRoute } = {},
|
||||||
|
} = getRoutes({ customAdminRoutes, customRoutes })
|
||||||
|
|
||||||
|
const logoutRoute = formatAdminURL({
|
||||||
|
serverURL,
|
||||||
|
adminRoute: incomingAdminRoute,
|
||||||
|
path: incomingLogoutRoute,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(logoutRoute)
|
||||||
|
await wait(500)
|
||||||
|
|
||||||
|
const adminRoute = formatAdminURL({ serverURL, adminRoute: incomingAdminRoute, path: '' })
|
||||||
|
const loginRoute = formatAdminURL({
|
||||||
|
serverURL,
|
||||||
|
adminRoute: incomingAdminRoute,
|
||||||
|
path: incomingLoginRoute,
|
||||||
|
})
|
||||||
|
const createFirstUserRoute = formatAdminURL({
|
||||||
|
serverURL,
|
||||||
|
adminRoute: incomingAdminRoute,
|
||||||
|
path: createFirstUser,
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(loginRoute)
|
||||||
|
await wait(500)
|
||||||
|
await page.fill('#field-email', data.email)
|
||||||
|
await page.fill('#field-password', data.password)
|
||||||
|
await wait(500)
|
||||||
|
await page.click('[type=submit]')
|
||||||
|
await page.waitForURL(adminRoute)
|
||||||
|
|
||||||
|
await expect(() => expect(page.url()).not.toContain(loginRoute)).toPass({
|
||||||
|
timeout: POLL_TOPASS_TIMEOUT,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(() => expect(page.url()).not.toContain(createFirstUserRoute)).toPass({
|
||||||
|
timeout: POLL_TOPASS_TIMEOUT,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a user in by navigating via click-ops instead of using page.goto()
|
||||||
|
*/
|
||||||
|
export async function loginClientSide(args: LoginArgs): Promise<void> {
|
||||||
|
const { customAdminRoutes, customRoutes, data = devUser, page, serverURL } = args
|
||||||
|
const {
|
||||||
|
routes: { admin: incomingAdminRoute } = {},
|
||||||
|
admin: { routes: { login: incomingLoginRoute, createFirstUser } = {} },
|
||||||
|
} = getRoutes({ customAdminRoutes, customRoutes })
|
||||||
|
|
||||||
|
const adminRoute = formatAdminURL({ serverURL, adminRoute: incomingAdminRoute, path: '' })
|
||||||
|
const loginRoute = formatAdminURL({
|
||||||
|
serverURL,
|
||||||
|
adminRoute: incomingAdminRoute,
|
||||||
|
path: incomingLoginRoute,
|
||||||
|
})
|
||||||
|
const createFirstUserRoute = formatAdminURL({
|
||||||
|
serverURL,
|
||||||
|
adminRoute: incomingAdminRoute,
|
||||||
|
path: createFirstUser,
|
||||||
|
})
|
||||||
|
|
||||||
|
if ((await page.locator('#nav-toggler').count()) > 0) {
|
||||||
|
// a user is already logged in - log them out
|
||||||
|
await openNav(page)
|
||||||
|
await expect(page.locator('.nav__controls [aria-label="Log out"]')).toBeVisible()
|
||||||
|
await page.locator('.nav__controls [aria-label="Log out"]').click()
|
||||||
|
|
||||||
|
if (await page.locator('dialog#leave-without-saving').isVisible()) {
|
||||||
|
await page.locator('dialog#leave-without-saving #confirm-action').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForURL(loginRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(500)
|
||||||
|
await page.fill('#field-email', data.email)
|
||||||
|
await page.fill('#field-password', data.password)
|
||||||
|
await wait(500)
|
||||||
|
await page.click('[type=submit]')
|
||||||
|
|
||||||
|
await expect(page.locator('.step-nav__home')).toBeVisible()
|
||||||
|
if ((await page.locator('a.step-nav__home').count()) > 0) {
|
||||||
|
await page.locator('a.step-nav__home').click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForURL(adminRoute)
|
||||||
|
|
||||||
|
await expect(() => expect(page.url()).not.toContain(loginRoute)).toPass({
|
||||||
|
timeout: POLL_TOPASS_TIMEOUT,
|
||||||
|
})
|
||||||
|
await expect(() => expect(page.url()).not.toContain(createFirstUserRoute)).toPass({
|
||||||
|
timeout: POLL_TOPASS_TIMEOUT,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
test/helpers/e2e/auth/logout.ts
Normal file
12
test/helpers/e2e/auth/logout.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Page } from 'playwright'
|
||||||
|
|
||||||
|
import { POLL_TOPASS_TIMEOUT } from 'playwright.config.js'
|
||||||
|
import { expect } from 'playwright/test'
|
||||||
|
|
||||||
|
export const logout = async (page: Page, serverURL: string) => {
|
||||||
|
await page.goto(`${serverURL}/admin/logout`)
|
||||||
|
|
||||||
|
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('/admin/login')
|
||||||
|
|
||||||
|
await expect(page.locator('.login')).toBeVisible()
|
||||||
|
}
|
||||||
@@ -8,13 +8,9 @@ import { fileURLToPath } from 'url'
|
|||||||
|
|
||||||
import type { Config } from './payload-types.js'
|
import type { Config } from './payload-types.js'
|
||||||
|
|
||||||
import {
|
import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
|
||||||
ensureCompilationIsDone,
|
|
||||||
initPageConsoleErrorCatch,
|
|
||||||
loginClientSide,
|
|
||||||
saveDocAndAssert,
|
|
||||||
} from '../helpers.js'
|
|
||||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
|
||||||
|
import { loginClientSide } from '../helpers/e2e/auth/login.js'
|
||||||
import { goToListDoc } from '../helpers/e2e/goToListDoc.js'
|
import { goToListDoc } from '../helpers/e2e/goToListDoc.js'
|
||||||
import {
|
import {
|
||||||
clearSelectInput,
|
clearSelectInput,
|
||||||
|
|||||||
Reference in New Issue
Block a user