diff --git a/docs/admin/views.mdx b/docs/admin/views.mdx index 45f4138a2d..97ffd28bbb 100644 --- a/docs/admin/views.mdx +++ b/docs/admin/views.mdx @@ -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. + + Custom views are public +
+ 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. +
+ ## Collection Views Collection Views are views that are scoped under the `/collections` route, such as the Collection List and Document Edit views. diff --git a/packages/next/src/utilities/initPage/index.ts b/packages/next/src/utilities/initPage/index.ts index e744d2d918..520d0ce960 100644 --- a/packages/next/src/utilities/initPage/index.ts +++ b/packages/next/src/utilities/initPage/index.ts @@ -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, diff --git a/packages/next/src/utilities/initPage/isCustomAdminView.ts b/packages/next/src/utilities/initPage/isCustomAdminView.ts new file mode 100644 index 0000000000..a76ac95a54 --- /dev/null +++ b/packages/next/src/utilities/initPage/isCustomAdminView.ts @@ -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 +} diff --git a/packages/next/src/utilities/initPage/shared.ts b/packages/next/src/utilities/initPage/shared.ts index 185d7ad7b7..883942b4b2 100644 --- a/packages/next/src/utilities/initPage/shared.ts +++ b/packages/next/src/utilities/initPage/shared.ts @@ -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 = ({ diff --git a/packages/next/src/views/NotFound/index.tsx b/packages/next/src/views/NotFound/index.tsx index 5b8047bbc0..c18e217937 100644 --- a/packages/next/src/views/NotFound/index.tsx +++ b/packages/next/src/views/NotFound/index.tsx @@ -67,6 +67,10 @@ export const NotFoundPage = async ({ const params = await paramsPromise + if (!initPageResult.req.user || !initPageResult.permissions.canAccessAdmin) { + return + } + return ( !!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: { diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 48996e9d4a..f0d3ea9cf1 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -39,7 +39,7 @@ export type ClientConfig = { Logo: MappedComponent } LogoutButton?: MappedComponent - } + } & Pick dependencies?: Record livePreview?: Omit } & Omit @@ -64,6 +64,6 @@ export const serverOnlyConfigProperties: readonly Partial = 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 ( +
+

{customNestedViewTitle}

+

This custom view was added through the Payload config:

+
    +
  • + components.views[key].Component +
  • +
+
+ +       + +
+
+ ) +} diff --git a/test/admin/config.ts b/test/admin/config.ts index 26d1fdd691..3532c44921 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -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: { diff --git a/test/admin/e2e/1/e2e.spec.ts b/test/admin/e2e/1/e2e.spec.ts index cbb90522a6..f4c59ae54f 100644 --- a/test/admin/e2e/1/e2e.spec.ts +++ b/test/admin/e2e/1/e2e.spec.ts @@ -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') diff --git a/test/admin/globals/Settings.ts b/test/admin/globals/Settings.ts new file mode 100644 index 0000000000..669137091a --- /dev/null +++ b/test/admin/globals/Settings.ts @@ -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', + }, + ], +} diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 528c56170e..74b684358c 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -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". diff --git a/test/admin/shared.ts b/test/admin/shared.ts index 41e687bc73..7c24349c1d 100644 --- a/test/admin/shared.ts +++ b/test/admin/shared.ts @@ -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' diff --git a/test/admin/slugs.ts b/test/admin/slugs.ts index 38899b97c5..9be4911a2e 100644 --- a/test/admin/slugs.ts +++ b/test/admin/slugs.ts @@ -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,