feat!: custom views are now public by default and fixed some issues with notFound page (#8820)
This PR aims to fix a few issues with the notFound page and custom views so it matches v2 behaviour: - Non authorised users should always be redirected to the login page regardless if not found or valid URL - Previously notFound would render for non users too potentially exposing valid but protected routes and creating a confusing workflow as the UI was being rendered as well - Custom views are now public by default - in our `admin` test suite, the `/admin/public-custom-view` is accessible to non users but `/admin/public-custom-view/protected-nested-view` is not unless the checkbox is true in the Settings global, there's e2e coverage for this - Fixes https://github.com/payloadcms/payload/issues/8716
This commit is contained in:
@@ -93,7 +93,7 @@ For more granular control, pass a configuration object instead. Payload exposes
|
||||
| **`path`** \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. |
|
||||
| **`exact`** | Boolean. When true, will only match if the path matches the `usePathname()` exactly. |
|
||||
| **`strict`** | When true, a path that has a trailing slash will only match a `location.pathname` with a trailing slash. This has no effect when there are additional URL segments in the pathname. |
|
||||
| **`sensitive`** | When true, will match if the path is case sensitive.
|
||||
| **`sensitive`** | When true, will match if the path is case sensitive.|
|
||||
| **`meta`** | Page metadata overrides to apply to this view within the Admin Panel. [More details](./metadata). |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
@@ -133,6 +133,12 @@ The above example shows how to add a new [Root View](#root-views), but the patte
|
||||
route.
|
||||
</Banner>
|
||||
|
||||
<Banner type="warning">
|
||||
<strong>Custom views are public</strong>
|
||||
<br />
|
||||
Custom views are public by default. If your view requires a user to be logged in or to have certain access rights, you should handle that within your view component yourself.
|
||||
</Banner>
|
||||
|
||||
## Collection Views
|
||||
|
||||
Collection Views are views that are scoped under the `/collections` route, such as the Collection List and Document Edit views.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getPayloadHMR } from '../getPayloadHMR.js'
|
||||
import { initReq } from '../initReq.js'
|
||||
import { getRouteInfo } from './handleAdminPage.js'
|
||||
import { handleAuthRedirect } from './handleAuthRedirect.js'
|
||||
import { isCustomAdminView } from './isCustomAdminView.js'
|
||||
import { isPublicAdminRoute } from './shared.js'
|
||||
|
||||
export const initPage = async ({
|
||||
@@ -133,7 +134,8 @@ export const initPage = async ({
|
||||
|
||||
if (
|
||||
!permissions.canAccessAdmin &&
|
||||
!isPublicAdminRoute({ adminRoute, config: payload.config, route })
|
||||
!isPublicAdminRoute({ adminRoute, config: payload.config, route }) &&
|
||||
!isCustomAdminView({ adminRoute, config: payload.config, route })
|
||||
) {
|
||||
redirectTo = handleAuthRedirect({
|
||||
config: payload.config,
|
||||
|
||||
35
packages/next/src/utilities/initPage/isCustomAdminView.ts
Normal file
35
packages/next/src/utilities/initPage/isCustomAdminView.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { AdminViewConfig, PayloadRequest, SanitizedConfig } from 'payload'
|
||||
|
||||
import { getRouteWithoutAdmin } from './shared.js'
|
||||
|
||||
/**
|
||||
* Returns an array of views marked with 'public: true' in the config
|
||||
*/
|
||||
export const isCustomAdminView = ({
|
||||
adminRoute,
|
||||
config,
|
||||
route,
|
||||
}: {
|
||||
adminRoute: string
|
||||
config: SanitizedConfig
|
||||
route: string
|
||||
}): boolean => {
|
||||
if (config.admin?.components?.views) {
|
||||
const isPublicAdminRoute = Object.entries(config.admin.components.views).some(([_, view]) => {
|
||||
const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route })
|
||||
|
||||
if (view.exact) {
|
||||
if (routeWithoutAdmin === view.path) {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
if (routeWithoutAdmin.startsWith(view.path)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
return isPublicAdminRoute
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -35,9 +35,10 @@ export const isPublicAdminRoute = ({
|
||||
config: SanitizedConfig
|
||||
route: string
|
||||
}): boolean => {
|
||||
return publicAdminRoutes.some((routeSegment) => {
|
||||
const isPublicAdminRoute = publicAdminRoutes.some((routeSegment) => {
|
||||
const segment = config.admin?.routes?.[routeSegment] || routeSegment
|
||||
const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route })
|
||||
|
||||
if (routeWithoutAdmin.startsWith(segment)) {
|
||||
return true
|
||||
} else if (routeWithoutAdmin.includes('/verify/')) {
|
||||
@@ -46,6 +47,8 @@ export const isPublicAdminRoute = ({
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return isPublicAdminRoute
|
||||
}
|
||||
|
||||
export const getRouteWithoutAdmin = ({
|
||||
|
||||
@@ -67,6 +67,10 @@ export const NotFoundPage = async ({
|
||||
|
||||
const params = await paramsPromise
|
||||
|
||||
if (!initPageResult.req.user || !initPageResult.permissions.canAccessAdmin) {
|
||||
return <NotFoundClient />
|
||||
}
|
||||
|
||||
return (
|
||||
<DefaultTemplate
|
||||
i18n={initPageResult.req.i18n}
|
||||
|
||||
@@ -66,24 +66,29 @@ export const RootPage = async ({
|
||||
|
||||
let dbHasUser = false
|
||||
|
||||
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const initPageResult = await initPage(initPageOptions)
|
||||
|
||||
dbHasUser = await initPageResult?.req.payload.db
|
||||
.findOne({
|
||||
collection: userSlug,
|
||||
req: initPageResult?.req,
|
||||
})
|
||||
?.then((doc) => !!doc)
|
||||
|
||||
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
|
||||
if (initPageResult?.req?.user) {
|
||||
notFound()
|
||||
}
|
||||
if (dbHasUser) {
|
||||
redirect(adminRoute)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof initPageResult?.redirectTo === 'string') {
|
||||
redirect(initPageResult.redirectTo)
|
||||
}
|
||||
|
||||
if (initPageResult) {
|
||||
dbHasUser = await initPageResult?.req.payload.db
|
||||
.findOne({
|
||||
collection: userSlug,
|
||||
req: initPageResult?.req,
|
||||
})
|
||||
?.then((doc) => !!doc)
|
||||
|
||||
const createFirstUserRoute = formatAdminURL({ adminRoute, path: _createFirstUserRoute })
|
||||
|
||||
const collectionConfig = config.collections.find(({ slug }) => slug === userSlug)
|
||||
@@ -102,6 +107,10 @@ export const RootPage = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!DefaultView?.Component && !DefaultView?.payloadComponent && !dbHasUser) {
|
||||
redirect(adminRoute)
|
||||
}
|
||||
|
||||
const createMappedView = getCreateMappedComponent({
|
||||
importMap,
|
||||
serverProps: {
|
||||
|
||||
@@ -39,7 +39,7 @@ export type ClientConfig = {
|
||||
Logo: MappedComponent
|
||||
}
|
||||
LogoutButton?: MappedComponent
|
||||
}
|
||||
} & Pick<SanitizedConfig['admin']['components'], 'views'>
|
||||
dependencies?: Record<string, MappedComponent>
|
||||
livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties>
|
||||
} & Omit<SanitizedConfig['admin'], 'components' | 'dependencies' | 'livePreview'>
|
||||
@@ -64,6 +64,6 @@ export const serverOnlyConfigProperties: readonly Partial<ServerOnlyRootProperti
|
||||
'email',
|
||||
'custom',
|
||||
'graphQL',
|
||||
'logger'
|
||||
'logger',
|
||||
// `admin`, `onInit`, `localization`, `collections`, and `globals` are all handled separately
|
||||
]
|
||||
|
||||
69
test/admin/components/views/CustomProtectedView/index.tsx
Normal file
69
test/admin/components/views/CustomProtectedView/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { AdminViewProps } from 'payload'
|
||||
|
||||
import { Button } from '@payloadcms/ui'
|
||||
import LinkImport from 'next/link.js'
|
||||
import { notFound, redirect } from 'next/navigation.js'
|
||||
import React from 'react'
|
||||
|
||||
import { customNestedViewTitle, customViewPath } from '../../../shared.js'
|
||||
import { settingsGlobalSlug } from '../../../slugs.js'
|
||||
|
||||
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
|
||||
|
||||
export const CustomProtectedView: React.FC<AdminViewProps> = async ({ initPageResult }) => {
|
||||
const {
|
||||
req: {
|
||||
payload: {
|
||||
config: {
|
||||
routes: { admin: adminRoute },
|
||||
},
|
||||
},
|
||||
user,
|
||||
},
|
||||
req,
|
||||
} = initPageResult
|
||||
|
||||
const settings = await req.payload.findGlobal({
|
||||
slug: settingsGlobalSlug,
|
||||
})
|
||||
|
||||
if (!settings?.canAccessProtected) {
|
||||
if (user) {
|
||||
redirect(`${adminRoute}/unauthorized`)
|
||||
} else {
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'calc(var(--base) * 2)',
|
||||
paddingLeft: 'var(--gutter-h)',
|
||||
paddingRight: 'var(--gutter-h)',
|
||||
}}
|
||||
>
|
||||
<h1 id="custom-view-title">{customNestedViewTitle}</h1>
|
||||
<p>This custom view was added through the Payload config:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>components.views[key].Component</code>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="custom-view__controls">
|
||||
<Button buttonStyle="secondary" el="link" Link={Link} to={`${adminRoute}`}>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
el="link"
|
||||
Link={Link}
|
||||
to={`${adminRoute}/${customViewPath}`}
|
||||
>
|
||||
Go to Custom View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import { GlobalGroup1A } from './globals/Group1A.js'
|
||||
import { GlobalGroup1B } from './globals/Group1B.js'
|
||||
import { GlobalHidden } from './globals/Hidden.js'
|
||||
import { GlobalNoApiView } from './globals/NoApiView.js'
|
||||
import { Settings } from './globals/Settings.js'
|
||||
import { seed } from './seed.js'
|
||||
import {
|
||||
customAdminRoutes,
|
||||
@@ -33,7 +34,11 @@ import {
|
||||
customParamViewPath,
|
||||
customRootViewMetaTitle,
|
||||
customViewPath,
|
||||
protectedCustomNestedViewPath,
|
||||
publicCustomViewPath,
|
||||
} from './shared.js'
|
||||
import { settingsGlobalSlug } from './slugs.js'
|
||||
|
||||
export default buildConfigWithDefaults({
|
||||
admin: {
|
||||
importMap: {
|
||||
@@ -80,6 +85,17 @@ export default buildConfigWithDefaults({
|
||||
path: customViewPath,
|
||||
strict: true,
|
||||
},
|
||||
ProtectedCustomNestedView: {
|
||||
Component: '/components/views/CustomProtectedView/index.js#CustomProtectedView',
|
||||
exact: true,
|
||||
path: protectedCustomNestedViewPath,
|
||||
},
|
||||
PublicCustomView: {
|
||||
Component: '/components/views/CustomView/index.js#CustomView',
|
||||
exact: true,
|
||||
path: publicCustomViewPath,
|
||||
strict: true,
|
||||
},
|
||||
CustomViewWithParam: {
|
||||
Component: '/components/views/CustomViewWithParam/index.js#CustomViewWithParam',
|
||||
path: customParamViewPath,
|
||||
@@ -144,6 +160,7 @@ export default buildConfigWithDefaults({
|
||||
CustomGlobalViews2,
|
||||
GlobalGroup1A,
|
||||
GlobalGroup1B,
|
||||
Settings,
|
||||
],
|
||||
i18n: {
|
||||
translations: {
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
customViewMetaTitle,
|
||||
customViewPath,
|
||||
customViewTitle,
|
||||
protectedCustomNestedViewPath,
|
||||
publicCustomViewPath,
|
||||
slugPluralLabel,
|
||||
} from '../../shared.js'
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
noApiViewCollectionSlug,
|
||||
noApiViewGlobalSlug,
|
||||
postsCollectionSlug,
|
||||
settingsGlobalSlug,
|
||||
} from '../../slugs.js'
|
||||
|
||||
const { beforeAll, beforeEach, describe } = test
|
||||
@@ -494,6 +497,30 @@ describe('admin1', () => {
|
||||
await expect(page.locator('h1#custom-view-title')).toContainText(customNestedViewTitle)
|
||||
})
|
||||
|
||||
test('root — should render public custom view', async () => {
|
||||
await page.goto(`${serverURL}${adminRoutes.routes.admin}${publicCustomViewPath}`)
|
||||
await page.waitForURL(`**${adminRoutes.routes.admin}${publicCustomViewPath}`)
|
||||
await expect(page.locator('h1#custom-view-title')).toContainText(customViewTitle)
|
||||
})
|
||||
|
||||
test('root — should render protected nested custom view', async () => {
|
||||
await page.goto(`${serverURL}${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`)
|
||||
await page.waitForURL(`**${adminRoutes.routes.admin}/unauthorized`)
|
||||
await expect(page.locator('.unauthorized')).toBeVisible()
|
||||
|
||||
await page.goto(globalURL.global(settingsGlobalSlug))
|
||||
|
||||
const checkbox = page.locator('#field-canAccessProtected')
|
||||
|
||||
await checkbox.check()
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
await page.goto(`${serverURL}${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`)
|
||||
await page.waitForURL(`**${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`)
|
||||
await expect(page.locator('h1#custom-view-title')).toContainText(customNestedViewTitle)
|
||||
})
|
||||
|
||||
test('collection - should render custom tab view', async () => {
|
||||
await page.goto(customViewsURL.create)
|
||||
await page.locator('#field-title').fill('Test')
|
||||
|
||||
13
test/admin/globals/Settings.ts
Normal file
13
test/admin/globals/Settings.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
import { settingsGlobalSlug } from '../slugs.js'
|
||||
|
||||
export const Settings: GlobalConfig = {
|
||||
slug: settingsGlobalSlug,
|
||||
fields: [
|
||||
{
|
||||
type: 'checkbox',
|
||||
name: 'canAccessProtected',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface Config {
|
||||
'custom-global-views-two': CustomGlobalViewsTwo;
|
||||
'group-globals-one': GroupGlobalsOne;
|
||||
'group-globals-two': GroupGlobalsTwo;
|
||||
settings: Setting;
|
||||
};
|
||||
locale: 'es' | 'en';
|
||||
user: User & {
|
||||
@@ -341,7 +342,6 @@ export interface PayloadLockedDocument {
|
||||
relationTo: 'disable-duplicate';
|
||||
value: string | DisableDuplicate;
|
||||
} | null);
|
||||
editedAt?: string | null;
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
@@ -455,6 +455,16 @@ export interface GroupGlobalsTwo {
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "settings".
|
||||
*/
|
||||
export interface Setting {
|
||||
id: string;
|
||||
canAccessProtected?: boolean | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
|
||||
@@ -6,6 +6,12 @@ export const slugPluralLabel = 'Posts'
|
||||
|
||||
export const customViewPath = '/custom-view'
|
||||
|
||||
export const customNestedViewPath = `${customViewPath}/nested-view`
|
||||
|
||||
export const publicCustomViewPath = '/public-custom-view'
|
||||
|
||||
export const protectedCustomNestedViewPath = `${publicCustomViewPath}/protected-nested-view`
|
||||
|
||||
export const customParamViewPathBase = '/custom-param'
|
||||
|
||||
export const customParamViewPath = `${customParamViewPathBase}/:id`
|
||||
@@ -14,8 +20,6 @@ export const customViewTitle = 'Custom View'
|
||||
|
||||
export const customParamViewTitle = 'Custom Param View'
|
||||
|
||||
export const customNestedViewPath = `${customViewPath}/nested-view`
|
||||
|
||||
export const customNestedViewTitle = 'Custom Nested View'
|
||||
|
||||
export const customEditLabel = 'Custom Edit Label'
|
||||
|
||||
@@ -34,6 +34,8 @@ export const globalSlug = 'global'
|
||||
export const group1GlobalSlug = 'group-globals-one'
|
||||
export const group2GlobalSlug = 'group-globals-two'
|
||||
export const hiddenGlobalSlug = 'hidden-global'
|
||||
|
||||
export const settingsGlobalSlug = 'settings'
|
||||
export const noApiViewGlobalSlug = 'global-no-api-view'
|
||||
export const globalSlugs = [
|
||||
customGlobalViews1GlobalSlug,
|
||||
|
||||
Reference in New Issue
Block a user