Skip to content

Commit 01ccbd4

Browse files
authored
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 #8716
1 parent 61b4f2e commit 01ccbd4

File tree

14 files changed

+219
-18
lines changed

14 files changed

+219
-18
lines changed

docs/admin/views.mdx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ For more granular control, pass a configuration object instead. Payload exposes
9393
| **`path`** \* | Any valid URL path or array of paths that [`path-to-regexp`](https://www.npmjs.com/package/path-to-regex) understands. |
9494
| **`exact`** | Boolean. When true, will only match if the path matches the `usePathname()` exactly. |
9595
| **`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. |
96-
| **`sensitive`** | When true, will match if the path is case sensitive.
96+
| **`sensitive`** | When true, will match if the path is case sensitive.|
9797
| **`meta`** | Page metadata overrides to apply to this view within the Admin Panel. [More details](./metadata). |
9898

9999
_\* 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
133133
route.
134134
</Banner>
135135

136+
<Banner type="warning">
137+
<strong>Custom views are public</strong>
138+
<br />
139+
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.
140+
</Banner>
141+
136142
## Collection Views
137143

138144
Collection Views are views that are scoped under the `/collections` route, such as the Collection List and Document Edit views.

packages/next/src/utilities/initPage/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { getPayloadHMR } from '../getPayloadHMR.js'
1212
import { initReq } from '../initReq.js'
1313
import { getRouteInfo } from './handleAdminPage.js'
1414
import { handleAuthRedirect } from './handleAuthRedirect.js'
15+
import { isCustomAdminView } from './isCustomAdminView.js'
1516
import { isPublicAdminRoute } from './shared.js'
1617

1718
export const initPage = async ({
@@ -133,7 +134,8 @@ export const initPage = async ({
133134

134135
if (
135136
!permissions.canAccessAdmin &&
136-
!isPublicAdminRoute({ adminRoute, config: payload.config, route })
137+
!isPublicAdminRoute({ adminRoute, config: payload.config, route }) &&
138+
!isCustomAdminView({ adminRoute, config: payload.config, route })
137139
) {
138140
redirectTo = handleAuthRedirect({
139141
config: payload.config,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { AdminViewConfig, PayloadRequest, SanitizedConfig } from 'payload'
2+
3+
import { getRouteWithoutAdmin } from './shared.js'
4+
5+
/**
6+
* Returns an array of views marked with 'public: true' in the config
7+
*/
8+
export const isCustomAdminView = ({
9+
adminRoute,
10+
config,
11+
route,
12+
}: {
13+
adminRoute: string
14+
config: SanitizedConfig
15+
route: string
16+
}): boolean => {
17+
if (config.admin?.components?.views) {
18+
const isPublicAdminRoute = Object.entries(config.admin.components.views).some(([_, view]) => {
19+
const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route })
20+
21+
if (view.exact) {
22+
if (routeWithoutAdmin === view.path) {
23+
return true
24+
}
25+
} else {
26+
if (routeWithoutAdmin.startsWith(view.path)) {
27+
return true
28+
}
29+
}
30+
return false
31+
})
32+
return isPublicAdminRoute
33+
}
34+
return false
35+
}

packages/next/src/utilities/initPage/shared.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,10 @@ export const isPublicAdminRoute = ({
3535
config: SanitizedConfig
3636
route: string
3737
}): boolean => {
38-
return publicAdminRoutes.some((routeSegment) => {
38+
const isPublicAdminRoute = publicAdminRoutes.some((routeSegment) => {
3939
const segment = config.admin?.routes?.[routeSegment] || routeSegment
4040
const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route })
41+
4142
if (routeWithoutAdmin.startsWith(segment)) {
4243
return true
4344
} else if (routeWithoutAdmin.includes('/verify/')) {
@@ -46,6 +47,8 @@ export const isPublicAdminRoute = ({
4647
return false
4748
}
4849
})
50+
51+
return isPublicAdminRoute
4952
}
5053

5154
export const getRouteWithoutAdmin = ({

packages/next/src/views/NotFound/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export const NotFoundPage = async ({
6767

6868
const params = await paramsPromise
6969

70+
if (!initPageResult.req.user || !initPageResult.permissions.canAccessAdmin) {
71+
return <NotFoundClient />
72+
}
73+
7074
return (
7175
<DefaultTemplate
7276
i18n={initPageResult.req.i18n}

packages/next/src/views/Root/index.tsx

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,29 @@ export const RootPage = async ({
6666

6767
let dbHasUser = false
6868

69+
const initPageResult = await initPage(initPageOptions)
70+
71+
dbHasUser = await initPageResult?.req.payload.db
72+
.findOne({
73+
collection: userSlug,
74+
req: initPageResult?.req,
75+
})
76+
?.then((doc) => !!doc)
77+
6978
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
70-
notFound()
79+
if (initPageResult?.req?.user) {
80+
notFound()
81+
}
82+
if (dbHasUser) {
83+
redirect(adminRoute)
84+
}
7185
}
7286

73-
const initPageResult = await initPage(initPageOptions)
74-
7587
if (typeof initPageResult?.redirectTo === 'string') {
7688
redirect(initPageResult.redirectTo)
7789
}
7890

7991
if (initPageResult) {
80-
dbHasUser = await initPageResult?.req.payload.db
81-
.findOne({
82-
collection: userSlug,
83-
req: initPageResult?.req,
84-
})
85-
?.then((doc) => !!doc)
86-
8792
const createFirstUserRoute = formatAdminURL({ adminRoute, path: _createFirstUserRoute })
8893

8994
const collectionConfig = config.collections.find(({ slug }) => slug === userSlug)
@@ -102,6 +107,10 @@ export const RootPage = async ({
102107
}
103108
}
104109

110+
if (!DefaultView?.Component && !DefaultView?.payloadComponent && !dbHasUser) {
111+
redirect(adminRoute)
112+
}
113+
105114
const createMappedView = getCreateMappedComponent({
106115
importMap,
107116
serverProps: {

packages/payload/src/config/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export type ClientConfig = {
3939
Logo: MappedComponent
4040
}
4141
LogoutButton?: MappedComponent
42-
}
42+
} & Pick<SanitizedConfig['admin']['components'], 'views'>
4343
dependencies?: Record<string, MappedComponent>
4444
livePreview?: Omit<LivePreviewConfig, ServerOnlyLivePreviewProperties>
4545
} & Omit<SanitizedConfig['admin'], 'components' | 'dependencies' | 'livePreview'>
@@ -64,6 +64,6 @@ export const serverOnlyConfigProperties: readonly Partial<ServerOnlyRootProperti
6464
'email',
6565
'custom',
6666
'graphQL',
67-
'logger'
67+
'logger',
6868
// `admin`, `onInit`, `localization`, `collections`, and `globals` are all handled separately
6969
]
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { AdminViewProps } from 'payload'
2+
3+
import { Button } from '@payloadcms/ui'
4+
import LinkImport from 'next/link.js'
5+
import { notFound, redirect } from 'next/navigation.js'
6+
import React from 'react'
7+
8+
import { customNestedViewTitle, customViewPath } from '../../../shared.js'
9+
import { settingsGlobalSlug } from '../../../slugs.js'
10+
11+
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
12+
13+
export const CustomProtectedView: React.FC<AdminViewProps> = async ({ initPageResult }) => {
14+
const {
15+
req: {
16+
payload: {
17+
config: {
18+
routes: { admin: adminRoute },
19+
},
20+
},
21+
user,
22+
},
23+
req,
24+
} = initPageResult
25+
26+
const settings = await req.payload.findGlobal({
27+
slug: settingsGlobalSlug,
28+
})
29+
30+
if (!settings?.canAccessProtected) {
31+
if (user) {
32+
redirect(`${adminRoute}/unauthorized`)
33+
} else {
34+
notFound()
35+
}
36+
}
37+
38+
return (
39+
<div
40+
style={{
41+
marginTop: 'calc(var(--base) * 2)',
42+
paddingLeft: 'var(--gutter-h)',
43+
paddingRight: 'var(--gutter-h)',
44+
}}
45+
>
46+
<h1 id="custom-view-title">{customNestedViewTitle}</h1>
47+
<p>This custom view was added through the Payload config:</p>
48+
<ul>
49+
<li>
50+
<code>components.views[key].Component</code>
51+
</li>
52+
</ul>
53+
<div className="custom-view__controls">
54+
<Button buttonStyle="secondary" el="link" Link={Link} to={`${adminRoute}`}>
55+
Go to Dashboard
56+
</Button>
57+
&nbsp; &nbsp; &nbsp;
58+
<Button
59+
buttonStyle="secondary"
60+
el="link"
61+
Link={Link}
62+
to={`${adminRoute}/${customViewPath}`}
63+
>
64+
Go to Custom View
65+
</Button>
66+
</div>
67+
</div>
68+
)
69+
}

test/admin/config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,19 @@ import { GlobalGroup1A } from './globals/Group1A.js'
2626
import { GlobalGroup1B } from './globals/Group1B.js'
2727
import { GlobalHidden } from './globals/Hidden.js'
2828
import { GlobalNoApiView } from './globals/NoApiView.js'
29+
import { Settings } from './globals/Settings.js'
2930
import { seed } from './seed.js'
3031
import {
3132
customAdminRoutes,
3233
customNestedViewPath,
3334
customParamViewPath,
3435
customRootViewMetaTitle,
3536
customViewPath,
37+
protectedCustomNestedViewPath,
38+
publicCustomViewPath,
3639
} from './shared.js'
40+
import { settingsGlobalSlug } from './slugs.js'
41+
3742
export default buildConfigWithDefaults({
3843
admin: {
3944
importMap: {
@@ -80,6 +85,17 @@ export default buildConfigWithDefaults({
8085
path: customViewPath,
8186
strict: true,
8287
},
88+
ProtectedCustomNestedView: {
89+
Component: '/components/views/CustomProtectedView/index.js#CustomProtectedView',
90+
exact: true,
91+
path: protectedCustomNestedViewPath,
92+
},
93+
PublicCustomView: {
94+
Component: '/components/views/CustomView/index.js#CustomView',
95+
exact: true,
96+
path: publicCustomViewPath,
97+
strict: true,
98+
},
8399
CustomViewWithParam: {
84100
Component: '/components/views/CustomViewWithParam/index.js#CustomViewWithParam',
85101
path: customParamViewPath,
@@ -144,6 +160,7 @@ export default buildConfigWithDefaults({
144160
CustomGlobalViews2,
145161
GlobalGroup1A,
146162
GlobalGroup1B,
163+
Settings,
147164
],
148165
i18n: {
149166
translations: {

test/admin/e2e/1/e2e.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import {
3535
customViewMetaTitle,
3636
customViewPath,
3737
customViewTitle,
38+
protectedCustomNestedViewPath,
39+
publicCustomViewPath,
3840
slugPluralLabel,
3941
} from '../../shared.js'
4042
import {
@@ -50,6 +52,7 @@ import {
5052
noApiViewCollectionSlug,
5153
noApiViewGlobalSlug,
5254
postsCollectionSlug,
55+
settingsGlobalSlug,
5356
} from '../../slugs.js'
5457

5558
const { beforeAll, beforeEach, describe } = test
@@ -494,6 +497,30 @@ describe('admin1', () => {
494497
await expect(page.locator('h1#custom-view-title')).toContainText(customNestedViewTitle)
495498
})
496499

500+
test('root — should render public custom view', async () => {
501+
await page.goto(`${serverURL}${adminRoutes.routes.admin}${publicCustomViewPath}`)
502+
await page.waitForURL(`**${adminRoutes.routes.admin}${publicCustomViewPath}`)
503+
await expect(page.locator('h1#custom-view-title')).toContainText(customViewTitle)
504+
})
505+
506+
test('root — should render protected nested custom view', async () => {
507+
await page.goto(`${serverURL}${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`)
508+
await page.waitForURL(`**${adminRoutes.routes.admin}/unauthorized`)
509+
await expect(page.locator('.unauthorized')).toBeVisible()
510+
511+
await page.goto(globalURL.global(settingsGlobalSlug))
512+
513+
const checkbox = page.locator('#field-canAccessProtected')
514+
515+
await checkbox.check()
516+
517+
await saveDocAndAssert(page)
518+
519+
await page.goto(`${serverURL}${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`)
520+
await page.waitForURL(`**${adminRoutes.routes.admin}${protectedCustomNestedViewPath}`)
521+
await expect(page.locator('h1#custom-view-title')).toContainText(customNestedViewTitle)
522+
})
523+
497524
test('collection - should render custom tab view', async () => {
498525
await page.goto(customViewsURL.create)
499526
await page.locator('#field-title').fill('Test')

0 commit comments

Comments
 (0)