diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx
index 5b6a9b90e..ff50572dd 100644
--- a/packages/next/src/views/Root/index.tsx
+++ b/packages/next/src/views/Root/index.tsx
@@ -10,7 +10,7 @@ import type {
SanitizedGlobalConfig,
} from 'payload'
-import { RootPageConfigProvider } from '@payloadcms/ui'
+import { PageConfigProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { notFound, redirect } from 'next/navigation.js'
@@ -300,7 +300,7 @@ export const RootPage = async ({
})
return (
-
+
{!templateType && {RenderedView}}
{templateType === 'minimal' && (
{RenderedView}
@@ -331,6 +331,6 @@ export const RootPage = async ({
{RenderedView}
)}
-
+
)
}
diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts
index 7d410ecdd..324ed63c7 100644
--- a/packages/payload/src/config/client.ts
+++ b/packages/payload/src/config/client.ts
@@ -63,7 +63,7 @@ export type UnauthenticatedClientConfig = {
globals: []
routes: ClientConfig['routes']
serverURL: ClientConfig['serverURL']
- unauthenticated: ClientConfig['unauthenticated']
+ unauthenticated: true
}
export const serverOnlyAdminConfigProperties: readonly Partial[] = []
diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts
index ad62e9f3c..e6e68373a 100644
--- a/packages/ui/src/exports/client/index.ts
+++ b/packages/ui/src/exports/client/index.ts
@@ -303,7 +303,7 @@ export {
RouteTransitionProvider,
useRouteTransition,
} 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 { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js'
export { useDocumentTitle } from '../../providers/DocumentTitle/index.js'
diff --git a/packages/ui/src/providers/Config/index.tsx b/packages/ui/src/providers/Config/index.tsx
index 20e3bfc8b..45769a93e 100644
--- a/packages/ui/src/providers/Config/index.tsx
+++ b/packages/ui/src/providers/Config/index.tsx
@@ -100,41 +100,42 @@ export const ConfigProvider: React.FC<{
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.
- * 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.
*
* 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.
+ *
+ * @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 config: ClientConfig
}> = ({ children, config: configFromProps }) => {
- const rootLayoutConfig = useConfig()
- const { config, getEntityConfig, setConfig } = rootLayoutConfig
+ const { config: rootConfig, setConfig: setRootConfig } = useConfig()
/**
- * 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.
+ * 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.
* 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.
*/
useEffect(() => {
- setConfig(configFromProps)
- }, [configFromProps, setConfig])
+ setRootConfig(configFromProps)
+ }, [configFromProps, setRootConfig])
- if (config !== configFromProps && config.unauthenticated !== configFromProps.unauthenticated) {
- // Between the unauthenticated config becoming authenticated (or the other way around) and the useEffect
- // running, the config will be stale. In order to avoid having the wrong config in the context in that
- // brief moment, we shadow the context here on the _page_ level and provide the updated config immediately.
- return (
-
- {children}
-
- )
+ // If this component receives a different config than what is in context from the layout, it is stale.
+ // While stale, we instantiate a new context provider that provides the new config until the root context is updated.
+ // Unfortunately, referential equality alone does not work bc the reference is lost during server/client serialization,
+ // so we need to also compare the `unauthenticated` property.
+ if (
+ rootConfig !== configFromProps &&
+ rootConfig.unauthenticated !== configFromProps.unauthenticated
+ ) {
+ return {children}
}
- return {children}
+ return children
}
diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts
index 4e58d4738..05b49f291 100644
--- a/test/access-control/e2e.spec.ts
+++ b/test/access-control/e2e.spec.ts
@@ -17,10 +17,10 @@ import {
ensureCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
- login,
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
+import { login } from '../helpers/e2e/auth/login.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
diff --git a/test/admin-root/e2e.spec.ts b/test/admin-root/e2e.spec.ts
index dd4a65a24..e8ff5d1ca 100644
--- a/test/admin-root/e2e.spec.ts
+++ b/test/admin-root/e2e.spec.ts
@@ -8,11 +8,11 @@ import { fileURLToPath } from 'url'
import {
ensureCompilationIsDone,
initPageConsoleErrorCatch,
- login,
saveDocAndAssert,
// throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
+import { login } from '../helpers/e2e/auth/login.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts
index c6217a172..598f253e9 100644
--- a/test/auth/e2e.spec.ts
+++ b/test/auth/e2e.spec.ts
@@ -2,6 +2,8 @@ import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
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 path from 'path'
import { fileURLToPath } from 'url'
@@ -15,7 +17,6 @@ import {
exactText,
getRoutes,
initPageConsoleErrorCatch,
- login,
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
@@ -388,21 +389,24 @@ describe('Auth', () => {
await saveDocAndAssert(page)
})
- test('ensure login page with redirect to users document redirects properly after login, without client error', async () => {
- await page.goto(url.admin)
-
- await page.goto(`${serverURL}/admin/logout`)
-
- await expect(page.locator('.login')).toBeVisible()
-
+ test('ensure `?redirect=` param is injected into the URL and handled properly after login', async () => {
const users = await payload.find({
collection: slug,
limit: 1,
})
+
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 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()
@@ -414,8 +418,37 @@ describe('Auth', () => {
.toBe(userDocumentRoute)
// Previously, this would crash the page with a "Cannot read properties of undefined (reading 'match')" error
-
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()
})
})
})
diff --git a/test/helpers.ts b/test/helpers.ts
index 27c0c1701..ce1687ba6 100644
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -18,18 +18,7 @@ import { devUser } from './credentials.js'
import { openNav } from './helpers/e2e/toggleNav.js'
import { POLL_TOPASS_TIMEOUT } from './playwright.config.js'
-type AdminRoutes = NonNullable['routes']
-
-type LoginArgs = {
- customAdminRoutes?: AdminRoutes
- customRoutes?: Config['routes']
- data?: {
- email: string
- password: string
- }
- page: Page
- serverURL: string
-}
+export type AdminRoutes = NonNullable['routes']
const random = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
@@ -177,110 +166,6 @@ export async function throttleTest({
return client
}
-/**
- * Logs a user in by navigating via click-ops instead of using page.goto()
- */
-export async function loginClientSide(args: LoginArgs): Promise {
- 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 {
- 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 {
const ua = page.evaluate(() => navigator.userAgent)
const isMac = (await ua).includes('Mac OS X')
diff --git a/test/helpers/e2e/auth/login.ts b/test/helpers/e2e/auth/login.ts
new file mode 100644
index 000000000..e49943357
--- /dev/null
+++ b/test/helpers/e2e/auth/login.ts
@@ -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 {
+ 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 {
+ 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,
+ })
+}
diff --git a/test/helpers/e2e/auth/logout.ts b/test/helpers/e2e/auth/logout.ts
new file mode 100644
index 000000000..b35da897a
--- /dev/null
+++ b/test/helpers/e2e/auth/logout.ts
@@ -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()
+}
diff --git a/test/plugin-multi-tenant/e2e.spec.ts b/test/plugin-multi-tenant/e2e.spec.ts
index 86d9d7f03..ede000fcd 100644
--- a/test/plugin-multi-tenant/e2e.spec.ts
+++ b/test/plugin-multi-tenant/e2e.spec.ts
@@ -8,13 +8,9 @@ import { fileURLToPath } from 'url'
import type { Config } from './payload-types.js'
-import {
- ensureCompilationIsDone,
- initPageConsoleErrorCatch,
- loginClientSide,
- saveDocAndAssert,
-} from '../helpers.js'
+import { ensureCompilationIsDone, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
+import { loginClientSide } from '../helpers/e2e/auth/login.js'
import { goToListDoc } from '../helpers/e2e/goToListDoc.js'
import {
clearSelectInput,