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:
Paul
2024-10-30 11:29:29 -06:00
committed by GitHub
parent 61b4f2efd7
commit 01ccbd48b0
14 changed files with 220 additions and 19 deletions

View File

@@ -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.

View File

@@ -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,

View 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
}

View File

@@ -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 = ({

View File

@@ -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}

View File

@@ -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: {

View File

@@ -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
]

View 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>
&nbsp; &nbsp; &nbsp;
<Button
buttonStyle="secondary"
el="link"
Link={Link}
to={`${adminRoute}/${customViewPath}`}
>
Go to Custom View
</Button>
</div>
</div>
)
}

View File

@@ -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: {

View File

@@ -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')

View 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',
},
],
}

View File

@@ -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".

View File

@@ -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'

View File

@@ -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,