diff --git a/packages/next/src/views/CreateFirstUser/index.tsx b/packages/next/src/views/CreateFirstUser/index.tsx index dd83082c9..604c03add 100644 --- a/packages/next/src/views/CreateFirstUser/index.tsx +++ b/packages/next/src/views/CreateFirstUser/index.tsx @@ -73,6 +73,7 @@ export async function CreateFirstUserView({ initPageResult }: AdminViewServerPro renderAllFields: true, req, schemaPath: collectionConfig.slug, + skipClientConfigAuth: true, skipValidation: true, }) diff --git a/packages/payload/src/admin/forms/Form.ts b/packages/payload/src/admin/forms/Form.ts index 035c062cf..061545128 100644 --- a/packages/payload/src/admin/forms/Form.ts +++ b/packages/payload/src/admin/forms/Form.ts @@ -132,6 +132,14 @@ export type BuildFormStateArgs = { returnLockStatus?: boolean schemaPath: string select?: SelectType + /** + * When true, sets `user: true` when calling `getClientConfig`. + * This will retrieve the client config in its entirety, even when unauthenticated. + * For example, the create-first-user view needs the entire config, but there is no user yet. + * + * @experimental This property is experimental and may change in the future. Use at your own discretion. + */ + skipClientConfigAuth?: boolean skipValidation?: boolean updateLastEdited?: boolean } & ( diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 324ed63c7..dc267f46d 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations' import type { DeepPartial } from 'ts-essentials' import type { ImportMap } from '../bin/generateImportMap/index.js' -import type { ClientBlock } from '../fields/config/types.js' +import type { ClientBlock, ClientField, Field } from '../fields/config/types.js' import type { BlockSlug, TypedUser } from '../index.js' import type { RootLivePreviewConfig, @@ -59,7 +59,12 @@ export type UnauthenticatedClientConfig = { routes: ClientConfig['admin']['routes'] user: ClientConfig['admin']['user'] } - collections: [ClientCollectionConfig] + collections: [ + { + auth: ClientCollectionConfig['auth'] + slug: string + }, + ] globals: [] routes: ClientConfig['routes'] serverURL: ClientConfig['serverURL'] @@ -99,8 +104,9 @@ export type CreateClientConfigArgs = { * If unauthenticated, the client config will omit some sensitive properties * such as field schemas, etc. This is useful for login and error pages where * the page source should not contain this information. - * Allow `true` to generate a client config for the "create first user" page - * where there is no user yet, but the config should be as complete. + * + * For example, allow `true` to generate a client config for the "create first user" page + * where there is no user yet, but the config should still be complete. */ user: true | TypedUser } @@ -114,12 +120,24 @@ export const createUnauthenticatedClientConfig = ({ */ clientConfig: ClientConfig }): UnauthenticatedClientConfig => { + /** + * To share memory, find the admin user collection from the existing client config. + */ + const adminUserCollection = clientConfig.collections.find( + ({ slug }) => slug === clientConfig.admin.user, + )! + return { admin: { routes: clientConfig.admin.routes, user: clientConfig.admin.user, }, - collections: [clientConfig.collections.find(({ slug }) => slug === clientConfig.admin.user)!], + collections: [ + { + slug: adminUserCollection.slug, + auth: adminUserCollection.auth, + }, + ], globals: [], routes: clientConfig.routes, serverURL: clientConfig.serverURL, diff --git a/packages/ui/src/utilities/buildFormState.ts b/packages/ui/src/utilities/buildFormState.ts index 6785b0225..0698f4dc4 100644 --- a/packages/ui/src/utilities/buildFormState.ts +++ b/packages/ui/src/utilities/buildFormState.ts @@ -128,6 +128,7 @@ export const buildFormState = async ( returnLockStatus, schemaPath = collectionSlug || globalSlug, select, + skipClientConfigAuth, skipValidation, updateLastEdited, } = args @@ -147,7 +148,12 @@ export const buildFormState = async ( const clientSchemaMap = getClientSchemaMap({ collectionSlug, - config: getClientConfig({ config, i18n, importMap: req.payload.importMap, user: req.user }), + config: getClientConfig({ + config, + i18n, + importMap: req.payload.importMap, + user: skipClientConfigAuth ? true : req.user, + }), globalSlug, i18n, payload, diff --git a/test/auth/BeforeDashboard.tsx b/test/auth/BeforeDashboard.tsx new file mode 100644 index 000000000..1250ba6b3 --- /dev/null +++ b/test/auth/BeforeDashboard.tsx @@ -0,0 +1,16 @@ +'use client' + +import { useConfig } from '@payloadcms/ui' + +export const BeforeDashboard = () => { + const { config } = useConfig() + + return ( +
+ {JSON.stringify(config, null, 2)} +
+ ) +} diff --git a/test/auth/BeforeLogin.tsx b/test/auth/BeforeLogin.tsx new file mode 100644 index 000000000..efdbc4824 --- /dev/null +++ b/test/auth/BeforeLogin.tsx @@ -0,0 +1,16 @@ +'use client' + +import { useConfig } from '@payloadcms/ui' + +export const BeforeLogin = () => { + const { config } = useConfig() + + return ( ++ {JSON.stringify(config, null, 2)} +
+ ) +} diff --git a/test/auth/config.ts b/test/auth/config.ts index 870e71888..831f25f10 100644 --- a/test/auth/config.ts +++ b/test/auth/config.ts @@ -21,6 +21,10 @@ export default buildConfigWithDefaults({ password: devUser.password, prefillOnly: true, }, + components: { + beforeDashboard: ['./BeforeDashboard.js#BeforeDashboard'], + beforeLogin: ['./BeforeLogin.js#BeforeLogin'], + }, importMap: { baseDir: path.resolve(dirname), }, @@ -185,6 +189,10 @@ export default buildConfigWithDefaults({ }, label: 'Auth Debug', }, + { + name: 'shouldNotShowInClientConfigUnlessAuthenticated', + type: 'text', + }, ], }, { diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 598f253e9..d44b10795 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -182,6 +182,32 @@ describe('Auth', () => { await saveDocAndAssert(page, '#action-save') }) + test('should protect the client config behind authentication', async () => { + await logout(page, serverURL) + + // This element is absolutely positioned and `opacity: 0` + await expect(page.locator('#unauthenticated-client-config')).toBeAttached() + + // Search for our uniquely identifiable field name + await expect( + page.locator('#unauthenticated-client-config', { + hasText: 'shouldNotShowInClientConfigUnlessAuthenticated', + }), + ).toHaveCount(0) + + await login({ page, serverURL }) + + await page.goto(serverURL + '/admin') + + await expect(page.locator('#authenticated-client-config')).toBeAttached() + + await expect( + page.locator('#authenticated-client-config', { + hasText: 'shouldNotShowInClientConfigUnlessAuthenticated', + }), + ).toHaveCount(1) + }) + test('should allow change password', async () => { await page.goto(url.account) const emailBeforeSave = await page.locator('#field-email').inputValue() diff --git a/test/auth/payload-types.ts b/test/auth/payload-types.ts index 97937fca4..a060945fa 100644 --- a/test/auth/payload-types.ts +++ b/test/auth/payload-types.ts @@ -348,6 +348,7 @@ export interface ApiKey { */ export interface PublicUser { id: string; + shouldNotShowInClientConfigUnlessAuthenticated?: string | null; updatedAt: string; createdAt: string; email: string; @@ -611,6 +612,7 @@ export interface ApiKeysSelect