feat!: consolidates admin.logoutRoute and admin.inactivityRoute into admin.routes (#6354)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export const handleAdminPage = ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissions.canAccessAdmin && !isAdminAuthRoute(route, adminRoute)) {
|
||||
if (!permissions.canAccessAdmin && !isAdminAuthRoute(config, route, adminRoute)) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
|
||||
@@ -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] ?? '')
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ export const initPage = async ({
|
||||
|
||||
if (redirectUnauthenticatedUser && !user) {
|
||||
handleAuthRedirect({
|
||||
adminRoute,
|
||||
config: payload.config,
|
||||
redirectUnauthenticatedUser,
|
||||
route,
|
||||
searchParams,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,23 +85,42 @@ 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
|
||||
|
||||
if (viewToRender === 'account') {
|
||||
initPageOptions.redirectUnauthenticatedUser = true
|
||||
ViewToRender = Account
|
||||
templateClassName = 'account'
|
||||
templateType = 'default'
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 2: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ export const UnauthorizedView: AdminViewComponent = ({ initPageResult }) => {
|
||||
i18n,
|
||||
payload: {
|
||||
config: {
|
||||
admin: { logoutRoute },
|
||||
admin: {
|
||||
routes: { logout: logoutRoute },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -16,7 +16,9 @@ const DefaultLogout: React.FC<{
|
||||
const config = useConfig()
|
||||
|
||||
const {
|
||||
admin: { logoutRoute },
|
||||
admin: {
|
||||
routes: { logout: logoutRoute },
|
||||
},
|
||||
routes: { admin },
|
||||
} = config
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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,7 +216,9 @@ 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`, {
|
||||
const autoLoginResult = await requests.post(
|
||||
`${serverURL}${api}/${userSlug}${loginRoute}`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
email: autoLogin.email,
|
||||
password: autoLogin.password,
|
||||
@@ -220,7 +227,8 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
'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
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user