Skip to content

Commit 329090c

Browse files
authored
fix(next): respect canAccessAdmin when a custom dashboard view is configured (#16105)
# Overview Ensures `canAccessAdmin` is respected when a custom dashboard view is configured with `path: "/"`. Previously, navigating directly to a collection URL would skip the access check, while navigating to `/admin` worked as expected. ## Key Changes The issue was in `isCustomAdminView` - the `startsWith` check caused any route starting with `/` to match a custom dashboard view, skipping the `canAccessAdmin` redirect for collection and document routes. ## Design Decisions The `isCustomAdminView` bypass exists for custom non-root views (e.g. a public page at `/status`) that handle their own access logic. The root path `/` is a special case - it always goes through the `canAccessAdmin` check regardless of whether it has been overridden. ```typescript // Before — matched every route when dashboard path was '/' if (routeWithoutAdmin.startsWith(view.path)) { return true } // After — root path always enforces canAccessAdmin; non-root uses safe exact/prefix match if ( view.path && view.path !== '/' && (routeWithoutAdmin === view.path || routeWithoutAdmin.startsWith(view.path + '/')) ) { return true } ``` Fixes #16097 --- - To see the specific tasks where the Asana app for GitHub is being used, see below: - https://app.asana.com/0/0/1213862851143618
1 parent 77cdb17 commit 329090c

6 files changed

Lines changed: 148 additions & 2 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import type { SanitizedConfig } from 'payload'
2+
3+
import { describe, expect, it } from 'vitest'
4+
5+
import { isCustomAdminView } from './isCustomAdminView.js'
6+
7+
describe('isCustomAdminView', () => {
8+
const adminRoute = '/admin'
9+
10+
const configWithCustomDashboard = {
11+
admin: {
12+
components: {
13+
views: {
14+
dashboard: {
15+
path: '/',
16+
},
17+
},
18+
},
19+
},
20+
} as unknown as SanitizedConfig
21+
22+
it('should not bypass admin access for the root admin route even with a custom dashboard view', () => {
23+
// The root path '/' maps to the dashboard — it is not a public custom view.
24+
// canAccessAdmin must still be enforced for the admin root.
25+
const result = isCustomAdminView({
26+
adminRoute,
27+
config: configWithCustomDashboard,
28+
route: '/admin',
29+
})
30+
31+
expect(result).toBe(false)
32+
})
33+
34+
it('should not match collection routes as custom dashboard views when dashboard path is "/"', () => {
35+
const result = isCustomAdminView({
36+
adminRoute,
37+
config: configWithCustomDashboard,
38+
route: '/admin/collections/tickets',
39+
})
40+
41+
expect(result).toBe(false)
42+
})
43+
44+
it('should not match document routes as custom dashboard views when dashboard path is "/"', () => {
45+
const result = isCustomAdminView({
46+
adminRoute,
47+
config: configWithCustomDashboard,
48+
route: '/admin/collections/tickets/123',
49+
})
50+
51+
expect(result).toBe(false)
52+
})
53+
54+
it('should return false when no custom views are configured', () => {
55+
const config = {} as SanitizedConfig
56+
57+
const result = isCustomAdminView({
58+
adminRoute,
59+
config,
60+
route: '/admin/collections/tickets',
61+
})
62+
63+
expect(result).toBe(false)
64+
})
65+
66+
it('should return true for a custom view with a non-root path', () => {
67+
const config = {
68+
admin: {
69+
components: {
70+
views: {
71+
myCustomView: {
72+
path: '/my-custom-view',
73+
},
74+
},
75+
},
76+
},
77+
} as unknown as SanitizedConfig
78+
79+
const result = isCustomAdminView({
80+
adminRoute,
81+
config,
82+
route: '/admin/my-custom-view',
83+
})
84+
85+
expect(result).toBe(true)
86+
})
87+
88+
it('should not match collection routes when a non-root custom view path is set', () => {
89+
const config = {
90+
admin: {
91+
components: {
92+
views: {
93+
myCustomView: {
94+
path: '/my-custom-view',
95+
},
96+
},
97+
},
98+
},
99+
} as unknown as SanitizedConfig
100+
101+
const result = isCustomAdminView({
102+
adminRoute,
103+
config,
104+
route: '/admin/collections/tickets',
105+
})
106+
107+
expect(result).toBe(false)
108+
})
109+
})

packages/next/src/utilities/isCustomAdminView.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ export const isCustomAdminView = ({
2323
return true
2424
}
2525
} else {
26-
if (routeWithoutAdmin.startsWith(view.path)) {
26+
if (
27+
view.path &&
28+
view.path !== '/' &&
29+
(routeWithoutAdmin === view.path || routeWithoutAdmin.startsWith(view.path + '/'))
30+
) {
2731
return true
2832
}
2933
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { DefaultDashboard as default } from '@payloadcms/next/views'

test/access-control/e2e.spec.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,30 @@ describe('Access Control', () => {
781781
await expect(page.locator('form.login__form')).toBeVisible()
782782
})
783783

784+
test('non-admin users should not be able to bypass admin access by navigating directly to a collection with a custom dashboard view set', async () => {
785+
await page.goto(url.logout)
786+
787+
await login({
788+
data: {
789+
email: nonAdminEmail,
790+
password: 'test',
791+
},
792+
page,
793+
serverURL,
794+
})
795+
796+
// Navigate directly to a collection URL, bypassing the dashboard
797+
await page.goto(new AdminUrlUtil(serverURL, readOnlySlug).list)
798+
799+
// Should be redirected to unauthorized, not the collection list
800+
await page.waitForURL(/\/unauthorized/)
801+
await expect(page.locator('.unauthorized .form-header h1')).toHaveText(
802+
'Unauthorized, this user does not have access to the admin panel.',
803+
)
804+
805+
await page.goto(url.logout)
806+
})
807+
784808
test('public users should not have access to access admin', async () => {
785809
await page.goto(url.logout)
786810

test/access-control/getConfig.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ export const getConfig: () => Partial<Config> = () => ({
8585
admin: {
8686
autoLogin: false,
8787
user: 'users',
88+
components: {
89+
views: {
90+
dashboard: {
91+
Component: './CustomDashboard.js#default',
92+
path: '/',
93+
},
94+
},
95+
},
8896
importMap: {
8997
baseDir: path.resolve(dirname),
9098
},

tsconfig.base.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
],
3333
"paths": {
3434
"@payloadcms/figma": ["../enterprise-plugins/packages/figma/src/index.ts"],
35-
"@payload-config": ["./test/evals/config.ts"],
35+
"@payload-config": ["./test/_community/config.ts"],
3636
"@payloadcms/admin-bar": ["./packages/admin-bar/src"],
3737
"@payloadcms/live-preview": ["./packages/live-preview/src"],
3838
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],

0 commit comments

Comments
 (0)