feat!: consolidates admin.logoutRoute and admin.inactivityRoute into admin.routes (#6354)

This commit is contained in:
Jacob Fletcher
2024-05-14 17:18:19 -04:00
committed by GitHub
parent 6e116a76fd
commit 6a0fffe002
26 changed files with 368 additions and 104 deletions

View File

@@ -45,8 +45,7 @@ All options for the Admin panel are defined in your base Payload config file.
| `components` | Component overrides that affect the entirety of the Admin panel. [More](/docs/admin/components) |
| `webpack` | Customize the Webpack config that's used to generate the Admin panel. [More](/docs/admin/webpack) |
| `vite` | Customize the Vite config that's used to generate the Admin panel. [More](/docs/admin/vite) |
| `logoutRoute` | The route for the `logout` page. |
| `inactivityRoute` | The route for the `logout` inactivity page. |
| `routes` | Replace built-in Admin Panel routes with your own custom routes. I.e. `{ logout: '/custom-logout', inactivity: 'custom-inactivity' }` |
### The Admin User Collection

View File

@@ -46,7 +46,7 @@ export const handleAdminPage = ({
}
}
if (!permissions.canAccessAdmin && !isAdminAuthRoute(route, adminRoute)) {
if (!permissions.canAccessAdmin && !isAdminAuthRoute(config, route, adminRoute)) {
notFound()
}

View File

@@ -4,17 +4,24 @@ import QueryString from 'qs'
import { isAdminAuthRoute, isAdminRoute } from './shared.js'
export const handleAuthRedirect = ({
adminRoute,
config,
redirectUnauthenticatedUser,
route,
searchParams,
}: {
adminRoute: string
config
redirectUnauthenticatedUser: boolean | string
route: string
searchParams: { [key: string]: string | string[] }
}) => {
if (!isAdminAuthRoute(route, adminRoute)) {
const {
admin: {
routes: { login: loginRouteFromConfig },
},
routes: { admin: adminRoute },
} = config
if (!isAdminAuthRoute(config, route, adminRoute)) {
if (searchParams && 'redirect' in searchParams) delete searchParams.redirect
const redirectRoute = encodeURIComponent(
@@ -23,14 +30,14 @@ export const handleAuthRedirect = ({
: undefined,
)
const adminLoginRoute = `${adminRoute}/login`
const adminLoginRoute = `${adminRoute}${loginRouteFromConfig}`
const customLoginRoute =
typeof redirectUnauthenticatedUser === 'string' ? redirectUnauthenticatedUser : undefined
const loginRoute = isAdminRoute(route, adminRoute)
? adminLoginRoute
: customLoginRoute || '/login'
: customLoginRoute || loginRouteFromConfig
const parsedLoginRouteSearchParams = QueryString.parse(loginRoute.split('?')[1] ?? '')

View File

@@ -78,7 +78,7 @@ export const initPage = async ({
if (redirectUnauthenticatedUser && !user) {
handleAuthRedirect({
adminRoute,
config: payload.config,
redirectUnauthenticatedUser,
route,
searchParams,

View File

@@ -1,17 +1,26 @@
export const authRoutes = [
'/login',
'/logout',
'/create-first-user',
'/forgot',
'/reset',
'/verify',
'/logout-inactivity',
import type { SanitizedConfig } from 'payload/types'
const authRouteKeys: (keyof SanitizedConfig['admin']['routes'])[] = [
'account',
'createFirstUser',
'forgot',
'login',
'logout',
'forgot',
'inactivity',
'unauthorized',
]
export const isAdminRoute = (route: string, adminRoute: string) => {
return route.startsWith(adminRoute)
}
export const isAdminAuthRoute = (route: string, adminRoute: string) => {
export const isAdminAuthRoute = (config: SanitizedConfig, route: string, adminRoute: string) => {
const authRoutes = config.admin?.routes
? Object.entries(config.admin.routes)
.filter(([key]) => authRouteKeys.includes(key as keyof SanitizedConfig['admin']['routes']))
.map(([_, value]) => value)
: []
return authRoutes.some((r) => route.replace(adminRoute, '').startsWith(r))
}

View File

@@ -22,6 +22,9 @@ export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult })
} = initPageResult
const {
admin: {
routes: { account: accountRoute },
},
routes: { admin },
} = config
@@ -32,7 +35,7 @@ export const ForgotPasswordView: React.FC<AdminViewProps> = ({ initPageResult })
<p>
<Translation
elements={{
'0': ({ children }) => <Link href={`${admin}/account`}>{children}</Link>,
'0': ({ children }) => <Link href={`${admin}${accountRoute}`}>{children}</Link>,
}}
i18nKey="authentication:loggedInChangePassword"
t={i18n.t}

View File

@@ -25,7 +25,11 @@ export const LoginForm: React.FC<{
const config = useConfig()
const {
admin: { autoLogin, user: userSlug },
admin: {
autoLogin,
routes: { forgot: forgotRoute },
user: userSlug,
},
routes: { admin, api },
} = config
@@ -98,7 +102,7 @@ export const LoginForm: React.FC<{
}
/>
</div>
<Link href={`${admin}/forgot`}>{t('authentication:forgotPasswordQuestion')}</Link>
<Link href={`${admin}${forgotRoute}`}>{t('authentication:forgotPasswordQuestion')}</Link>
<FormSubmit>{t('authentication:login')}</FormSubmit>
</Form>
)

View File

@@ -29,6 +29,9 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
} = req
const {
admin: {
routes: { account: accountRoute },
},
routes: { admin },
} = config
@@ -40,7 +43,7 @@ export const ResetPassword: React.FC<AdminViewProps> = ({ initPageResult, params
<p>
<Translation
elements={{
'0': ({ children }) => <Link href={`${admin}/account`}>{children}</Link>,
'0': ({ children }) => <Link href={`${admin}${accountRoute}`}>{children}</Link>,
}}
i18nKey="authentication:loggedInChangePassword"
t={i18n.t}

View File

@@ -15,20 +15,27 @@ import { ResetPassword, resetPasswordBaseClass } from '../ResetPassword/index.js
import { UnauthorizedView } from '../Unauthorized/index.js'
import { Verify, verifyBaseClass } from '../Verify/index.js'
import { getCustomViewByRoute } from './getCustomViewByRoute.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'
const baseClasses = {
account: 'account',
forgot: forgotPasswordBaseClass,
login: loginBaseClass,
reset: resetPasswordBaseClass,
verify: verifyBaseClass,
}
const oneSegmentViews = {
'create-first-user': CreateFirstUserView,
type OneSegmentViews = {
[K in keyof SanitizedConfig['admin']['routes']]: AdminViewComponent
}
const oneSegmentViews: OneSegmentViews = {
account: Account,
createFirstUser: CreateFirstUserView,
forgot: ForgotPasswordView,
inactivity: LogoutInactivity,
login: LoginView,
logout: LogoutView,
'logout-inactivity': LogoutInactivity,
unauthorized: UnauthorizedView,
}
@@ -78,22 +85,41 @@ export const getViewFromConfig = ({
break
}
case 1: {
if (oneSegmentViews[segmentOne] && segmentOne !== 'account') {
// users can override the default routes via `admin.routes` config
// i.e.{ admin: { routes: { logout: '/sign-out', inactivity: '/idle' }}}
let viewToRender: keyof typeof oneSegmentViews
if (config.admin.routes) {
const matchedRoute = Object.entries(config.admin.routes).find(([, route]) => {
return isPathMatchingRoute({
currentRoute,
exact: true,
path: `${adminRoute}${route}`,
})
})
if (matchedRoute) {
viewToRender = matchedRoute[0] as keyof typeof oneSegmentViews
}
}
if (oneSegmentViews[viewToRender]) {
// --> /account
// --> /create-first-user
// --> /forgot
// --> /login
// --> /logout
// --> /logout-inactivity
// --> /unauthorized
ViewToRender = oneSegmentViews[segmentOne]
templateClassName = baseClasses[segmentOne]
ViewToRender = oneSegmentViews[viewToRender]
templateClassName = baseClasses[viewToRender]
templateType = 'minimal'
} else if (segmentOne === 'account') {
// --> /account
initPageOptions.redirectUnauthenticatedUser = true
ViewToRender = Account
templateClassName = 'account'
templateType = 'default'
if (viewToRender === 'account') {
initPageOptions.redirectUnauthenticatedUser = true
templateType = 'default'
}
}
break
}

View File

@@ -34,7 +34,10 @@ export const RootPage = async ({
const config = await configPromise
const {
admin: { user: userSlug },
admin: {
routes: { createFirstUser: createFirstUserRoute },
user: userSlug,
},
routes: { admin: adminRoute },
} = config
@@ -66,13 +69,13 @@ export const RootPage = async ({
})
?.then((doc) => !!doc)
const createFirstUserRoute = `${adminRoute}/create-first-user`
const routeWithAdmin = `${adminRoute}${createFirstUserRoute}`
if (!dbHasUser && currentRoute !== createFirstUserRoute) {
redirect(createFirstUserRoute)
if (!dbHasUser && currentRoute !== routeWithAdmin) {
redirect(routeWithAdmin)
}
if (dbHasUser && currentRoute === createFirstUserRoute) {
if (dbHasUser && currentRoute === routeWithAdmin) {
redirect(adminRoute)
}
}

View File

@@ -19,7 +19,9 @@ export const UnauthorizedView: AdminViewComponent = ({ initPageResult }) => {
i18n,
payload: {
config: {
admin: { logoutRoute },
admin: {
routes: { logout: logoutRoute },
},
},
},
},

View File

@@ -7,11 +7,18 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
custom: {},
dateFormat: 'MMMM do yyyy, h:mm a',
disable: false,
inactivityRoute: '/logout-inactivity',
logoutRoute: '/logout',
meta: {
titleSuffix: '- Payload',
},
routes: {
account: '/account',
createFirstUser: '/create-first-user',
forgot: '/forgot',
inactivity: '/logout-inactivity',
login: '/login',
logout: '/logout',
unauthorized: '/unauthorized',
},
},
bin: [],
collections: [],

View File

@@ -60,13 +60,11 @@ export default joi.object({
custom: joi.object().pattern(joi.string(), joi.any()),
dateFormat: joi.string(),
disable: joi.bool(),
inactivityRoute: joi.string(),
livePreview: joi.object({
...livePreviewSchema,
collections: joi.array().items(joi.string()),
globals: joi.array().items(joi.string()),
}),
logoutRoute: joi.string(),
meta: joi.object().keys({
icons: joi
.alternatives()
@@ -78,6 +76,15 @@ export default joi.object({
ogImage: joi.string(),
titleSuffix: joi.string(),
}),
routes: joi.object({
account: joi.string(),
createFirstUser: joi.string(),
forgot: joi.string(),
inactivity: joi.string(),
login: joi.string(),
logout: joi.string(),
unauthorized: joi.string(),
}),
user: joi.string(),
}),
bin: joi.array().items(

View File

@@ -453,14 +453,10 @@ export type Config = {
dateFormat?: string
/** If set to true, the entire Admin panel will be disabled. */
disable?: boolean
/** The route the user will be redirected to after being inactive for too long. */
inactivityRoute?: string
livePreview?: LivePreviewConfig & {
collections?: string[]
globals?: string[]
}
/** The route for the logout page. */
logoutRoute?: string
/** Base meta data to use for the Admin Panel. Included properties are titleSuffix, ogImage, and favicon. */
meta?: {
/**
@@ -482,6 +478,22 @@ export type Config = {
*/
titleSuffix?: string
}
routes?: {
/** The route for the account page. */
account?: string
/** The route for the create first user page. */
createFirstUser?: string
/** The route for the forgot password page. */
forgot?: string
/** The route the user will be redirected to after being inactive for too long. */
inactivity?: string
/** The route for the login page. */
login?: string
/** The route for the logout page. */
logout?: string
/** The route for the unauthorized page. */
unauthorized?: string
}
/** The slug of a Collection that you want be used to log in to the Admin dashboard. */
user?: string
}

View File

@@ -20,6 +20,9 @@ export const AppHeader: React.FC = () => {
const { t } = useTranslation()
const {
admin: {
routes: { account: accountRoute },
},
localization,
routes: { admin: adminRoute },
} = useConfig()
@@ -86,7 +89,7 @@ export const AppHeader: React.FC = () => {
<LinkElement
aria-label={t('authentication:account')}
className={`${baseClass}__account`}
href={`${adminRoute}/account`}
href={`${adminRoute}${accountRoute}`}
tabIndex={0}
>
<Account />

View File

@@ -16,7 +16,9 @@ const DefaultLogout: React.FC<{
const config = useConfig()
const {
admin: { logoutRoute },
admin: {
routes: { logout: logoutRoute },
},
routes: { admin },
} = config

View File

@@ -22,7 +22,9 @@ export const StayLoggedInModal: React.FC = () => {
const config = useConfig()
const {
admin: { logoutRoute },
admin: {
routes: { logout: logoutRoute },
},
routes: { admin },
} = config
const { toggleModal } = useModal()

View File

@@ -5,22 +5,21 @@ import React from 'react'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { DefaultAccountIcon } from './Default/index.js'
export { DefaultAccountIcon } from './Default/index.js'
import { GravatarAccountIcon } from './Gravatar/index.js'
export { GravatarAccountIcon } from './Gravatar/index.js'
export const Account = () => {
const {
admin: { avatar: Avatar },
admin: {
routes: { account: accountRoute },
},
routes: { admin: adminRoute },
} = useConfig()
const { user } = useAuth()
const pathname = usePathname()
const isOnAccountPage = pathname === `${adminRoute}/account`
const isOnAccountPage = pathname === `${adminRoute}${accountRoute}`
if (!user?.email || Avatar === 'default') return <DefaultAccountIcon active={isOnAccountPage} />
if (Avatar === 'gravatar') return <GravatarAccountIcon />
if (Avatar) return <Avatar active={isOnAccountPage} />
return <DefaultAccountIcon active={isOnAccountPage} />
}

View File

@@ -47,7 +47,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const config = useConfig()
const {
admin: { autoLogin, inactivityRoute: logoutInactivityRoute, user: userSlug },
admin: {
autoLogin,
routes: { inactivity: logoutInactivityRoute },
routes: { login: loginRoute },
user: userSlug,
},
routes: { admin, api },
serverURL,
} = config
@@ -211,16 +216,19 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} else if (autoLogin && autoLogin.prefillOnly !== true) {
// auto log-in with the provided autoLogin credentials. This is used in dev mode
// so you don't have to log in over and over again
const autoLoginResult = await requests.post(`${serverURL}${api}/${userSlug}/login`, {
body: JSON.stringify({
email: autoLogin.email,
password: autoLogin.password,
}),
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
const autoLoginResult = await requests.post(
`${serverURL}${api}/${userSlug}${loginRoute}`,
{
body: JSON.stringify({
email: autoLogin.email,
password: autoLogin.password,
}),
headers: {
'Accept-Language': i18n.language,
'Content-Type': 'application/json',
},
},
})
)
if (autoLoginResult.status === 200) {
const autoLoginJson = await autoLoginResult.json()
setUser(autoLoginJson.user)
@@ -253,6 +261,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
searchParams,
admin,
revokeTokenAndExpire,
loginRoute,
])
// On mount, get user and set

View File

@@ -8,12 +8,18 @@ import { wait } from 'payload/utilities'
import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import type { Config, ReadOnlyCollection, RestrictedVersion } from './payload-types.js'
import type {
Config,
NonAdminUser,
ReadOnlyCollection,
RestrictedVersion,
} from './payload-types.js'
import {
closeNav,
ensureAutoLoginAndCompilationIsDone,
exactText,
getAdminRoutes,
initPageConsoleErrorCatch,
login,
openDocControls,
@@ -58,6 +64,7 @@ describe('access control', () => {
let restrictedVersionsUrl: AdminUrlUtil
let serverURL: string
let context: BrowserContext
let logoutURL: string
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
@@ -75,6 +82,15 @@ describe('access control', () => {
await login({ page, serverURL })
await ensureAutoLoginAndCompilationIsDone({ page, serverURL })
const {
admin: {
routes: { logout: logoutRoute },
},
routes: { admin: adminRoute },
} = getAdminRoutes({})
logoutURL = `${serverURL}${adminRoute}${logoutRoute}`
})
test('field without read access should not show', async () => {
@@ -352,8 +368,8 @@ describe('access control', () => {
await expect(page.locator('.dashboard')).toBeVisible()
await page.goto(`${serverURL}/admin/logout`)
await page.waitForURL(`${serverURL}/admin/logout`)
await page.goto(logoutURL)
await page.waitForURL(logoutURL)
await login({
page,
@@ -366,8 +382,8 @@ describe('access control', () => {
await expect(page.locator('.next-error-h1')).toBeVisible()
await page.goto(`${serverURL}/admin/logout`)
await page.waitForURL(`${serverURL}/admin/logout`)
await page.goto(logoutURL)
await page.waitForURL(logoutURL)
// Log back in for the next test
await login({
@@ -387,10 +403,12 @@ describe('access control', () => {
await expect(page.locator('.dashboard')).toBeVisible()
await page.goto(`${serverURL}/admin/logout`)
await page.waitForURL(`${serverURL}/admin/logout`)
await page.goto(logoutURL)
await page.waitForURL(logoutURL)
const nonAdminUser = await payload.login({
const nonAdminUser: NonAdminUser & {
token?: string
} = await payload.login({
collection: nonAdminUserSlug,
data: {
email: nonAdminUserEmail,
@@ -398,7 +416,7 @@ describe('access control', () => {
},
})
context.addCookies([
await context.addCookies([
{
name: 'payload-token',
value: nonAdminUser.token,
@@ -418,5 +436,5 @@ async function createDoc(data: any): Promise<TypeWithID & Record<string, unknown
return payload.create({
collection: slug,
data,
})
}) as any as Promise<TypeWithID & Record<string, unknown>>
}

View File

@@ -7,9 +7,12 @@ import React from 'react'
export const Logout: React.FC = () => {
const config = useConfig()
const {
admin: { logoutRoute },
admin: {
routes: { logout: logoutRoute },
},
routes: { admin },
} = config
return (
<a href={`${admin}${logoutRoute}#custom`}>
<LogOut />

View File

@@ -1,4 +1,5 @@
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { CustomIdRow } from './collections/CustomIdRow.js'
import { CustomIdTab } from './collections/CustomIdTab.js'
import { CustomViews1 } from './collections/CustomViews1.js'
@@ -35,7 +36,12 @@ import { GlobalGroup1B } from './globals/Group1B.js'
import { GlobalHidden } from './globals/Hidden.js'
import { GlobalNoApiView } from './globals/NoApiView.js'
import { seed } from './seed.js'
import { customNestedViewPath, customParamViewPath, customViewPath } from './shared.js'
import {
customAdminRoutes,
customNestedViewPath,
customParamViewPath,
customViewPath,
} from './shared.js'
export default buildConfigWithDefaults({
admin: {
components: {
@@ -75,6 +81,7 @@ export default buildConfigWithDefaults({
},
},
},
routes: customAdminRoutes,
meta: {
icons: [
{

View File

@@ -13,7 +13,9 @@ import {
checkPageTitle,
ensureAutoLoginAndCompilationIsDone,
exactText,
getAdminRoutes,
initPageConsoleErrorCatch,
login,
openDocControls,
openDocDrawer,
openNav,
@@ -23,6 +25,7 @@ import {
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import {
customAdminRoutes,
customEditLabel,
customNestedTabViewPath,
customNestedTabViewTitle,
@@ -61,7 +64,7 @@ import { fileURLToPath } from 'url'
import type { PayloadTestSDK } from '../helpers/sdk/index.js'
import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -72,6 +75,8 @@ describe('admin', () => {
let customViewsURL: AdminUrlUtil
let disableDuplicateURL: AdminUrlUtil
let serverURL: string
let adminRoutes: ReturnType<typeof getAdminRoutes>
let loginURL: string
beforeAll(async ({ browser }, testInfo) => {
const prebuild = Boolean(process.env.CI)
@@ -95,7 +100,12 @@ describe('admin', () => {
serverURL,
snapshotKey: 'adminTests',
})
await ensureAutoLoginAndCompilationIsDone({ page, serverURL })
await ensureAutoLoginAndCompilationIsDone({ page, serverURL, customAdminRoutes })
adminRoutes = getAdminRoutes({ customAdminRoutes })
loginURL = `${serverURL}${adminRoutes.routes.admin}${adminRoutes.admin.routes.login}`
})
beforeEach(async () => {
await reInitializeDB({
@@ -103,7 +113,7 @@ describe('admin', () => {
snapshotKey: 'adminTests',
})
await ensureAutoLoginAndCompilationIsDone({ page, serverURL })
await ensureAutoLoginAndCompilationIsDone({ page, serverURL, customAdminRoutes })
})
describe('metadata', () => {
@@ -130,6 +140,28 @@ describe('admin', () => {
})
})
describe('routing', () => {
test('should use custom logout route', async () => {
await page.goto(`${serverURL}${adminRoutes.routes.admin}${adminRoutes.admin.routes.logout}`)
await page.waitForURL(
`${serverURL}${adminRoutes.routes.admin}${adminRoutes.admin.routes.logout}`,
)
await expect(() => expect(page.url()).not.toContain(loginURL)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// Ensure auto-login logged the user back in
await expect(() => expect(page.url()).toBe(`${serverURL}${adminRoutes.routes.admin}`)).toPass(
{
timeout: POLL_TOPASS_TIMEOUT,
},
)
})
})
describe('navigation', () => {
test('nav — should navigate to collection', async () => {
await page.goto(postsUrl.admin)
@@ -206,7 +238,7 @@ describe('admin', () => {
test('breadcrumbs — should navigate from list to dashboard', async () => {
await page.goto(postsUrl.list)
await page.locator('.step-nav a[href="/admin"]').click()
await page.locator(`.step-nav a[href="${adminRoutes.routes.admin}"]`).click()
expect(page.url()).toContain(postsUrl.admin)
})
@@ -214,7 +246,7 @@ describe('admin', () => {
const { id } = await createPost()
await page.goto(postsUrl.edit(id))
const collectionBreadcrumb = page.locator(
`.step-nav a[href="/admin/collections/${postsCollectionSlug}"]`,
`.step-nav a[href="${adminRoutes.routes.admin}/collections/${postsCollectionSlug}"]`,
)
await expect(collectionBreadcrumb).toBeVisible()
await expect(collectionBreadcrumb).toHaveText(slugPluralLabel)
@@ -248,16 +280,16 @@ describe('admin', () => {
describe('custom views', () => {
test('root — should render custom view', async () => {
await page.goto(`${serverURL}/admin${customViewPath}`)
await page.waitForURL(`**/admin${customViewPath}`)
await page.goto(`${serverURL}${adminRoutes.routes.admin}${customViewPath}`)
await page.waitForURL(`**${adminRoutes.routes.admin}${customViewPath}`)
await expect(page.locator('h1#custom-view-title')).toContainText(customViewTitle)
})
test('root — should render custom nested view', async () => {
await page.goto(`${serverURL}/admin${customNestedViewPath}`)
await page.goto(`${serverURL}${adminRoutes.routes.admin}${customNestedViewPath}`)
const pageURL = page.url()
const pathname = new URL(pageURL).pathname
expect(pathname).toEqual(`/admin${customNestedViewPath}`)
expect(pathname).toEqual(`${adminRoutes.routes.admin}${customNestedViewPath}`)
await expect(page.locator('h1#custom-view-title')).toContainText(customNestedViewTitle)
})
@@ -831,7 +863,10 @@ describe('admin', () => {
const { id } = await createPost()
await page.reload()
const linkCell = page.locator(`${tableRowLocator} td`).nth(1).locator('a')
await expect(linkCell).toHaveAttribute('href', `/admin/collections/posts/${id}`)
await expect(linkCell).toHaveAttribute(
'href',
`${adminRoutes.routes.admin}/collections/posts/${id}`,
)
// open the column controls
await page.locator('.list-controls__toggle-columns').click()
@@ -849,7 +884,10 @@ describe('admin', () => {
await page.locator('.cell-id').waitFor({ state: 'detached' })
// recheck that the 2nd cell is still a link
await expect(linkCell).toHaveAttribute('href', `/admin/collections/posts/${id}`)
await expect(linkCell).toHaveAttribute(
'href',
`${adminRoutes.routes.admin}/collections/posts/${id}`,
)
})
test('should filter rows', async () => {

View File

@@ -34,3 +34,8 @@ export const customNestedTabViewTitle = 'Custom Nested Tab View'
export const customCollectionParamViewPathBase = '/custom-param'
export const customCollectionParamViewPath = `${customCollectionParamViewPathBase}/:slug`
export const customAdminRoutes = {
logout: '/custom-logout',
inactivity: '/custom-inactivity',
}

View File

@@ -1,4 +1,5 @@
import type { Page } from '@playwright/test'
import type { SanitizedConfig } from 'payload/types'
import { expect, test } from '@playwright/test'
import { devUser } from 'credentials.js'
@@ -12,6 +13,7 @@ import type { Config } from './payload-types.js'
import {
ensureAutoLoginAndCompilationIsDone,
getAdminRoutes,
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../helpers.js'
@@ -31,8 +33,28 @@ const headers = {
'Content-Type': 'application/json',
}
const createFirstUser = async ({ page, serverURL }: { page: Page; serverURL: string }) => {
await page.goto(serverURL + '/admin/create-first-user')
const createFirstUser = async ({
page,
serverURL,
customAdminRoutes,
customRoutes,
}: {
customAdminRoutes?: SanitizedConfig['admin']['routes']
customRoutes?: SanitizedConfig['routes']
page: Page
serverURL: string
}) => {
const {
admin: {
routes: { createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getAdminRoutes({
customAdminRoutes,
customRoutes,
})
await page.goto(serverURL + `${adminRoute}${createFirstUserRoute}`)
await page.locator('#field-email').fill(devUser.email)
await page.locator('#field-password').fill(devUser.password)
await page.locator('#field-confirm-password').fill(devUser.password)

View File

@@ -1,6 +1,8 @@
import type { BrowserContext, ChromiumBrowserContext, Locator, Page } from '@playwright/test'
import type { Config } from 'payload/config'
import { expect } from '@playwright/test'
import { defaults } from 'payload/config'
import { wait } from 'payload/utilities'
import shelljs from 'shelljs'
import { setTimeout } from 'timers/promises'
@@ -9,11 +11,15 @@ import { devUser } from './credentials.js'
import { POLL_TOPASS_TIMEOUT } from './playwright.config.js'
type FirstRegisterArgs = {
customAdminRoutes?: Config['admin']['routes']
customRoutes?: Config['routes']
page: Page
serverURL: string
}
type LoginArgs = {
customAdminRoutes?: Config['admin']['routes']
customRoutes?: Config['routes']
data?: {
email: string
password: string
@@ -49,20 +55,36 @@ const networkConditions = {
export async function ensureAutoLoginAndCompilationIsDone({
page,
serverURL,
customAdminRoutes,
customRoutes,
}: {
customAdminRoutes?: Config['admin']['routes']
customRoutes?: Config['routes']
page: Page
serverURL: string
}): Promise<void> {
const adminURL = `${serverURL}/admin`
const {
admin: {
routes: { login: loginRoute, createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getAdminRoutes({ customAdminRoutes, customRoutes })
const adminURL = `${serverURL}${adminRoute}`
await page.goto(adminURL)
await page.waitForURL(adminURL)
await expect(() => expect(page.url()).not.toContain(`/admin/login`)).toPass({
await expect(() => expect(page.url()).not.toContain(`${adminRoute}${loginRoute}`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await expect(() => expect(page.url()).not.toContain(`/admin/create-first-user`)).toPass({
await expect(() =>
expect(page.url()).not.toContain(`${adminRoute}${createFirstUserRoute}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// Check if hero is there
await expect(page.locator('.dashboard__label').first()).toBeVisible()
}
@@ -98,34 +120,47 @@ export async function throttleTest({
}
export async function firstRegister(args: FirstRegisterArgs): Promise<void> {
const { page, serverURL } = args
const { page, serverURL, customAdminRoutes, customRoutes } = args
await page.goto(`${serverURL}/admin`)
const {
routes: { admin: adminRoute },
} = getAdminRoutes({ customAdminRoutes, customRoutes })
await page.goto(`${serverURL}${adminRoute}`)
await page.fill('#field-email', devUser.email)
await page.fill('#field-password', devUser.password)
await page.fill('#field-confirm-password', devUser.password)
await wait(500)
await page.click('[type=submit]')
await page.waitForURL(`${serverURL}/admin`)
await page.waitForURL(`${serverURL}${adminRoute}`)
}
export async function login(args: LoginArgs): Promise<void> {
const { page, serverURL, data = devUser } = args
const { page, serverURL, data = devUser, customAdminRoutes, customRoutes } = args
await page.goto(`${serverURL}/admin/login`)
await page.waitForURL(`${serverURL}/admin/login`)
const {
admin: {
routes: { login: loginRoute, createFirstUser: createFirstUserRoute },
},
routes: { admin: adminRoute },
} = getAdminRoutes({ customAdminRoutes, customRoutes })
await page.goto(`${serverURL}${adminRoute}${loginRoute}`)
await page.waitForURL(`${serverURL}${adminRoute}${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(`${serverURL}/admin`)
await page.waitForURL(`${serverURL}${adminRoute}`)
await expect(() => expect(page.url()).not.toContain(`/admin/login`)).toPass({
await expect(() => expect(page.url()).not.toContain(`${adminRoute}${loginRoute}`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await expect(() => expect(page.url()).not.toContain(`/admin/create-first-user`)).toPass({
await expect(() =>
expect(page.url()).not.toContain(`${adminRoute}${createFirstUserRoute}`),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}
@@ -288,3 +323,42 @@ export function describeIfInCIOrHasLocalstack(): jest.Describe {
return describe
}
type AdminRoutes = Config['admin']['routes']
export function getAdminRoutes({
customRoutes,
customAdminRoutes,
}: {
customAdminRoutes?: AdminRoutes
customRoutes?: Config['routes']
}): {
admin: {
routes: AdminRoutes
}
routes: Config['routes']
} {
let routes = defaults.routes
let adminRoutes = defaults.admin.routes
if (customAdminRoutes) {
adminRoutes = {
...adminRoutes,
...customAdminRoutes,
}
}
if (customRoutes) {
routes = {
...routes,
...customRoutes,
}
}
return {
admin: {
routes: adminRoutes,
},
routes,
}
}