chore!: adjusts auth hydration from server (#7545)

Fixes https://github.com/payloadcms/payload/issues/6823

Allows the server to initialize the AuthProvider via props. Renames
`HydrateClientUser` to `HydrateAuthProvider`. It now only hydrates the
permissions as the user can be set from props. Permissions can be
initialized from props, but still need to be hydrated for some pages as
access control can be specific to docs/lists etc.

**BREAKING CHANGE**
- Renames exported `HydrateClientUser` to `HydrateAuthProvider`
This commit is contained in:
Jarrod Flesch
2024-08-07 11:10:53 -04:00
committed by GitHub
parent 4a20a63563
commit e905675a05
19 changed files with 190 additions and 109 deletions

View File

@@ -14,6 +14,7 @@ import {
} from '@payloadcms/ui'
import { EntityType, formatAdminURL, groupNavItems } from '@payloadcms/ui/shared'
import LinkWithDefault from 'next/link.js'
import { usePathname } from 'next/navigation.js'
import React, { Fragment } from 'react'
const baseClass = 'nav'
@@ -21,6 +22,7 @@ const baseClass = 'nav'
export const DefaultNavClient: React.FC = () => {
const { permissions } = useAuth()
const { isEntityVisible } = useEntityVisibility()
const pathname = usePathname()
const {
collections,
@@ -84,17 +86,11 @@ export const DefaultNavClient: React.FC = () => {
LinkWithDefault) as typeof LinkWithDefault.default
const LinkElement = Link || 'a'
const activeCollection = window?.location?.pathname
?.split('/')
.find(
(_, index, arr) =>
arr[index - 1] === 'collections' || arr[index - 1] === 'globals',
)
const activeCollection = pathname.startsWith(href)
return (
<LinkElement
className={[`${baseClass}__link`, activeCollection === entity?.slug && `active`]
className={[`${baseClass}__link`, activeCollection && `active`]
.filter(Boolean)
.join(' ')}
href={href}
@@ -102,9 +98,11 @@ export const DefaultNavClient: React.FC = () => {
key={i}
tabIndex={!navOpen ? -1 : undefined}
>
<span className={`${baseClass}__link-icon`}>
<ChevronIcon direction="right" />
</span>
{activeCollection && (
<span className={`${baseClass}__link-icon`}>
<ChevronIcon direction="right" />
</span>
)}
<span className={`${baseClass}__link-label`}>{entityLabel}</span>
</LinkElement>
)

View File

@@ -110,16 +110,9 @@
&__link {
display: flex;
align-items: center;
&.active {
.nav__link-icon {
display: block;
}
}
}
&__link-icon {
display: none;
margin-right: calc(var(--base) * 0.25);
top: -1px;
position: relative;

View File

@@ -1,12 +1,13 @@
import type { AcceptedLanguages, I18nClient } from '@payloadcms/translations'
import type { SanitizedConfig } from 'payload'
import type { PayloadRequest, SanitizedConfig } from 'payload'
import { initI18n, rtlLanguages } from '@payloadcms/translations'
import { RootProvider } from '@payloadcms/ui'
import '@payloadcms/ui/scss/app.scss'
import { buildComponentMap } from '@payloadcms/ui/utilities/buildComponentMap'
import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
import { createClientConfig, parseCookies } from 'payload'
import { createClientConfig, createLocalReq, parseCookies } from 'payload'
import * as qs from 'qs-esm'
import React from 'react'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
@@ -52,6 +53,20 @@ export const RootLayout = async ({
language: languageCode,
})
const req = await createLocalReq(
{
fallbackLocale: null,
req: {
headers,
host: headers.get('host'),
i18n,
url: `${payload.config.serverURL}`,
} as PayloadRequest,
},
payload,
)
const { permissions, user } = await payload.auth({ headers, req })
const clientConfig = await createClientConfig({ config, t: i18n.t })
const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode)
@@ -100,9 +115,11 @@ export const RootLayout = async ({
fallbackLang={clientConfig.i18n.fallbackLanguage}
languageCode={languageCode}
languageOptions={languageOptions}
permissions={permissions}
switchLanguageServerAction={switchLanguageServerAction}
theme={theme}
translations={i18n.translations}
user={user}
>
{wrappedChildren}
</RootProvider>

View File

@@ -1,6 +1,6 @@
import type { AdminViewProps, ServerSideEditViewProps } from 'payload'
import { DocumentInfoProvider, HydrateClientUser } from '@payloadcms/ui'
import { DocumentInfoProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderCustomComponent } from '@payloadcms/ui/shared'
import { notFound } from 'next/navigation.js'
import React from 'react'
@@ -82,7 +82,7 @@ export const Account: React.FC<AdminViewProps> = async ({
i18n={i18n}
permissions={permissions}
/>
<HydrateClientUser permissions={permissions} user={user} />
<HydrateAuthProvider permissions={permissions} />
<RenderCustomComponent
CustomComponent={
typeof CustomAccountComponent === 'function' ? CustomAccountComponent : undefined

View File

@@ -1,7 +1,7 @@
import type { EntityToGroup } from '@payloadcms/ui/shared'
import type { AdminViewProps } from 'payload'
import { HydrateClientUser } from '@payloadcms/ui'
import { HydrateAuthProvider } from '@payloadcms/ui'
import { EntityType, RenderCustomComponent, groupNavItems } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React, { Fragment } from 'react'
@@ -79,7 +79,6 @@ export const Dashboard: React.FC<AdminViewProps> = ({ initPageResult, params, se
return (
<Fragment>
<HydrateClientUser permissions={permissions} user={user} />
<RenderCustomComponent
CustomComponent={
typeof CustomDashboardComponent === 'function' ? CustomDashboardComponent : undefined

View File

@@ -1,13 +1,12 @@
import type { AdminViewComponent, AdminViewProps, EditViewComponent } from 'payload'
import { DocumentInfoProvider, EditDepthProvider, HydrateClientUser } from '@payloadcms/ui'
import { DocumentInfoProvider, EditDepthProvider, HydrateAuthProvider } from '@payloadcms/ui'
import {
RenderCustomComponent,
formatAdminURL,
isEditing as getIsEditing,
} from '@payloadcms/ui/shared'
import { notFound, redirect } from 'next/navigation.js'
import { deepCopyObjectSimple } from 'payload'
import React from 'react'
import type { GenerateEditViewMetadata } from './getMetaBySegment.js'
@@ -213,6 +212,7 @@ export const Document: React.FC<AdminViewProps> = async ({
permissions={permissions}
/>
)}
<HydrateAuthProvider permissions={permissions} />
{/**
* After bumping the Next.js canary to 104, and React to 19.0.0-rc-06d0b89e-20240801" we have to deepCopy the permissions object (https://github.com/payloadcms/payload/pull/7541).
* If both HydrateClientUser and RenderCustomComponent receive the same permissions object (same object reference), we get a
@@ -221,7 +221,6 @@ export const Document: React.FC<AdminViewProps> = async ({
*
* // TODO: Revisit this in the future and figure out why this is happening. Might be a React/Next.js bug. We don't know why it happens, and a future React/Next version might unbreak this (keep an eye on this and remove deepCopyObjectSimple if that's the case)
*/}
<HydrateClientUser permissions={deepCopyObjectSimple(permissions)} user={user} />
<EditDepthProvider
depth={1}
key={`${collectionSlug || globalSlug}${locale?.code ? `-${locale?.code}` : ''}`}

View File

@@ -1,7 +1,7 @@
import type { AdminViewProps, Where } from 'payload'
import {
HydrateClientUser,
HydrateAuthProvider,
ListInfoProvider,
ListQueryProvider,
TableColumnsProvider,
@@ -138,7 +138,7 @@ export const ListView: React.FC<AdminViewProps> = async ({
return (
<Fragment>
<HydrateClientUser permissions={permissions} user={user} />
<HydrateAuthProvider permissions={permissions} />
<ListInfoProvider
collectionConfig={createClientCollectionConfig({
collection: collectionConfig,

View File

@@ -2,7 +2,7 @@ import type { I18n } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type { AdminViewComponent, SanitizedConfig } from 'payload'
import { HydrateClientUser } from '@payloadcms/ui'
import { HydrateAuthProvider } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import React, { Fragment } from 'react'
@@ -58,21 +58,18 @@ export const NotFoundPage = async ({
})
return (
<Fragment>
<HydrateClientUser permissions={initPageResult.permissions} user={initPageResult.req.user} />
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={initPageResult.req.user}
visibleEntities={initPageResult.visibleEntities}
>
<NotFoundClient />
</DefaultTemplate>
</Fragment>
<DefaultTemplate
i18n={initPageResult.req.i18n}
locale={initPageResult.locale}
params={params}
payload={initPageResult.req.payload}
permissions={initPageResult.permissions}
searchParams={searchParams}
user={initPageResult.req.user}
visibleEntities={initPageResult.visibleEntities}
>
<NotFoundClient />
</DefaultTemplate>
)
}

View File

@@ -82,7 +82,7 @@ const generateLabelFromValue = (
}
} else if (relatedDoc) {
// Handle non-polymorphic `hasMany` relationships or fallback
if (typeof relatedDoc.id !== 'undefined') {
if (typeof relatedDoc?.id !== 'undefined') {
valueToReturn = relatedDoc.id
} else {
valueToReturn = relatedDoc

View File

@@ -1,12 +1,12 @@
import type {
CollectionPermission,
Document,
EditViewComponent,
GlobalPermission,
OptionObject,
} from 'payload'
import { notFound } from 'next/navigation.js'
import {
type CollectionPermission,
type Document,
type EditViewComponent,
type GlobalPermission,
type OptionObject,
deepCopyObjectSimple,
} from 'payload'
import React from 'react'
import { getLatestVersion } from '../Versions/getLatestVersion.js'
@@ -55,8 +55,18 @@ export const VersionView: EditViewComponent = async (props) => {
})
if (collectionConfig?.versions?.drafts) {
latestDraftVersion = await getLatestVersion(payload, slug, 'draft', 'collection')
latestPublishedVersion = await getLatestVersion(payload, slug, 'published', 'collection')
latestDraftVersion = await getLatestVersion({
slug,
type: 'collection',
payload,
status: 'draft',
})
latestPublishedVersion = await getLatestVersion({
slug,
type: 'collection',
payload,
status: 'published',
})
}
} catch (error) {
return notFound()
@@ -80,8 +90,18 @@ export const VersionView: EditViewComponent = async (props) => {
})
if (globalConfig?.versions?.drafts) {
latestDraftVersion = await getLatestVersion(payload, slug, 'draft', 'global')
latestPublishedVersion = await getLatestVersion(payload, slug, 'published', 'global')
latestDraftVersion = await getLatestVersion({
slug,
type: 'global',
payload,
status: 'draft',
})
latestPublishedVersion = await getLatestVersion({
slug,
type: 'global',
payload,
status: 'published',
})
}
} catch (error) {
return notFound()
@@ -116,7 +136,14 @@ export const VersionView: EditViewComponent = async (props) => {
return (
<DefaultVersionView
doc={doc}
docPermissions={docPermissions}
/**
* After bumping the Next.js canary to 104, and React to 19.0.0-rc-06d0b89e-20240801" we have to deepCopy the permissions object (https://github.com/payloadcms/payload/pull/7541).
* If both HydrateClientUser and RenderCustomComponent receive the same permissions object (same object reference), we get a
* "TypeError: Cannot read properties of undefined (reading '$$typeof')" error
*
* // TODO: Revisit this in the future and figure out why this is happening. Might be a React/Next.js bug. We don't know why it happens, and a future React/Next version might unbreak this (keep an eye on this and remove deepCopyObjectSimple if that's the case)
*/
docPermissions={deepCopyObjectSimple(docPermissions)}
initialComparisonDoc={latestVersion}
latestDraftVersion={latestDraftVersion?.id}
latestPublishedVersion={latestPublishedVersion?.id}

View File

@@ -1,4 +1,19 @@
export async function getLatestVersion(payload, slug, status, type = 'collection') {
import type { Payload } from 'payload'
type ReturnType = {
id: string
updatedAt: string
} | null
type Args = {
payload: Payload
slug: string
status: 'draft' | 'published'
type: 'collection' | 'global'
}
export async function getLatestVersion(args: Args): Promise<ReturnType> {
const { slug, type = 'collection', payload, status } = args
try {
const sharedOptions = {
depth: 0,
@@ -22,11 +37,16 @@ export async function getLatestVersion(payload, slug, status, type = 'collection
...sharedOptions,
})
if (!response.docs.length) {
return null
}
return {
id: response.docs[0].id,
updatedAt: response.docs[0].updatedAt,
}
} catch (e) {
console.error(e)
return null
}
}

View File

@@ -62,13 +62,18 @@ export const VersionsView: EditViewComponent = async (props) => {
},
})
if (collectionConfig?.versions?.drafts) {
latestDraftVersion = await getLatestVersion(payload, collectionSlug, 'draft', 'collection')
latestPublishedVersion = await getLatestVersion(
latestDraftVersion = await getLatestVersion({
slug: collectionSlug,
type: 'collection',
payload,
collectionSlug,
'published',
'collection',
)
status: 'draft',
})
latestPublishedVersion = await getLatestVersion({
slug: collectionSlug,
type: 'collection',
payload,
status: 'published',
})
}
} catch (error) {
console.error(error) // eslint-disable-line no-console
@@ -90,8 +95,18 @@ export const VersionsView: EditViewComponent = async (props) => {
})
if (globalConfig?.versions?.drafts) {
latestDraftVersion = await getLatestVersion(payload, globalSlug, 'draft', 'global')
latestPublishedVersion = await getLatestVersion(payload, globalSlug, 'published', 'global')
latestDraftVersion = await getLatestVersion({
slug: globalSlug,
type: 'global',
payload,
status: 'draft',
})
latestPublishedVersion = await getLatestVersion({
slug: globalSlug,
type: 'global',
payload,
status: 'published',
})
}
} catch (error) {
console.error(error) // eslint-disable-line no-console

View File

@@ -0,0 +1,27 @@
'use client'
import type { Permissions } from 'payload'
import { useEffect } from 'react'
import { useAuth } from '../../providers/Auth/index.js'
/**
* The Auth Provider wraps the entire app
* but each page has specific permissions
*
* i.e. access control on documents/fields on a document
*/
type Props = {
permissions: Permissions
}
export function HydrateAuthProvider({ permissions }: Props) {
const { setPermissions } = useAuth()
useEffect(() => {
setPermissions(permissions)
}, [permissions, setPermissions])
return null
}

View File

@@ -1,21 +0,0 @@
'use client'
import type { PayloadRequest, Permissions } from 'payload'
import { useEffect } from 'react'
import { useAuth } from '../../providers/Auth/index.js'
export const HydrateClientUser: React.FC<{
permissions: Permissions
user: PayloadRequest['user']
}> = ({ permissions, user }) => {
const { setPermissions, setUser } = useAuth()
useEffect(() => {
setUser(user)
setPermissions(permissions)
}, [user, permissions, setUser, setPermissions])
return null
}

View File

@@ -38,7 +38,7 @@ export { ErrorPill } from '../../elements/ErrorPill/index.js'
export { GenerateConfirmation } from '../../elements/GenerateConfirmation/index.js'
export { Gutter } from '../../elements/Gutter/index.js'
export { Hamburger } from '../../elements/Hamburger/index.js'
export { HydrateClientUser } from '../../elements/HydrateClientUser/index.js'
export { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js'
export { ListControls } from '../../elements/ListControls/index.js'
export { useListDrawer } from '../../elements/ListDrawer/index.js'
export { ListSelection } from '../../elements/ListSelection/index.js'

View File

@@ -33,8 +33,17 @@ const Context = createContext({} as AuthContext)
const maxTimeoutTime = 2147483647
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<ClientUser | null>()
type Props = {
children: React.ReactNode
permissions?: Permissions
user?: ClientUser | null
}
export function AuthProvider({
children,
permissions: initialPermissions,
user: initialUser,
}: Props) {
const [user, setUser] = useState<ClientUser | null>(initialUser)
const [tokenInMemory, setTokenInMemory] = useState<string>()
const [tokenExpiration, setTokenExpiration] = useState<number>()
const pathname = usePathname()
@@ -51,7 +60,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
serverURL,
} = config
const [permissions, setPermissions] = useState<Permissions>()
const [permissions, setPermissions] = useState<Permissions>(initialPermissions)
const { i18n } = useTranslation()
const { closeAllModals, openModal } = useModal()

View File

@@ -1,6 +1,6 @@
'use client'
import type { I18nClient, Language } from '@payloadcms/translations'
import type { ClientConfig, LanguageOptions } from 'payload'
import type { ClientConfig, LanguageOptions, Permissions, User } from 'payload'
import { ModalContainer, ModalProvider } from '@faceless-ui/modal'
import { ScrollInfoProvider } from '@faceless-ui/scroll-info'
@@ -38,9 +38,11 @@ type Props = {
fallbackLang: ClientConfig['i18n']['fallbackLanguage']
languageCode: string
languageOptions: LanguageOptions
permissions: Permissions
switchLanguageServerAction?: (lang: string) => Promise<void>
theme: Theme
translations: I18nClient['translations']
user: User | null
}
export const RootProvider: React.FC<Props> = ({
@@ -51,9 +53,11 @@ export const RootProvider: React.FC<Props> = ({
fallbackLang,
languageCode,
languageOptions,
permissions,
switchLanguageServerAction,
theme,
translations,
user,
}) => {
return (
<Fragment>
@@ -81,7 +85,7 @@ export const RootProvider: React.FC<Props> = ({
<ScrollInfoProvider>
<SearchParamsProvider>
<ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)">
<AuthProvider>
<AuthProvider permissions={permissions} user={user}>
<PreferencesProvider>
<ThemeProvider cookiePrefix={config.cookiePrefix} theme={theme}>
<ParamsProvider>

View File

@@ -158,7 +158,7 @@ describe('auth', () => {
test('should have up-to-date user in `useAuth` hook', async () => {
await page.goto(url.account)
await page.waitForURL(url.account)
await expect(page.locator('#users-api-result')).toHaveText('')
await expect(page.locator('#users-api-result')).toHaveText('Hello, world!')
await expect(page.locator('#use-auth-result')).toHaveText('Hello, world!')
const field = page.locator('#field-custom')
await field.fill('Goodbye, world!')

View File

@@ -22,7 +22,7 @@
* - specify locales to show
*/
import type { Page } from '@playwright/test'
import type { BrowserContext, Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import path from 'path'
@@ -40,6 +40,7 @@ import {
initPageConsoleErrorCatch,
saveDocAndAssert,
selectTableRow,
throttleTest,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
@@ -86,6 +87,8 @@ const waitForAutoSaveToRunAndComplete = async (page: Page) => {
await waitForAutoSaveToComplete(page)
}
let context: BrowserContext
describe('versions', () => {
let page: Page
let url: AdminUrlUtil
@@ -100,7 +103,7 @@ describe('versions', () => {
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
;({ payload, serverURL } = await initPayloadE2ENoConfig<Config>({ dirname }))
const context = await browser.newContext()
context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -317,14 +320,14 @@ describe('versions', () => {
await saveDocAndAssert(page, '#action-save-draft')
const savedDocURL = page.url()
await page.goto(`${savedDocURL}/versions`)
await page.waitForURL(new RegExp(`${savedDocURL}/versions`))
await page.waitForURL(`${savedDocURL}/versions`)
const row2 = page.locator('tbody .row-2')
const versionID = await row2.locator('.cell-id').textContent()
await page.goto(`${savedDocURL}/versions/${versionID}`)
await page.waitForURL(new RegExp(`${savedDocURL}/versions/${versionID}`))
await page.waitForURL(`${savedDocURL}/versions/${versionID}`)
await page.locator('.restore-version__button').click()
await page.locator('button:has-text("Confirm")').click()
await page.waitForURL(new RegExp(savedDocURL))
await page.waitForURL(savedDocURL)
await expect(page.locator('#field-title')).toHaveValue('v1')
})
@@ -417,9 +420,7 @@ describe('versions', () => {
const spanishTitle = 'spanish title'
const newDescription = 'new description'
await page.goto(autosaveURL.create)
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await wait(500)
await waitForAutoSaveToComplete(page)
const titleField = page.locator('#field-title')
await expect(titleField).toBeEnabled()
await titleField.fill(englishTitle)
@@ -483,9 +484,7 @@ describe('versions', () => {
test('collection — autosave should only update the current document', async () => {
await page.goto(autosaveURL.create)
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await wait(500)
await waitForAutoSaveToComplete(page)
await expect(page.locator('#field-title')).toBeEnabled()
await page.locator('#field-title').fill('first post title')
await expect(page.locator('#field-description')).toBeEnabled()
@@ -493,8 +492,6 @@ describe('versions', () => {
await saveDocAndAssert(page)
await waitForAutoSaveToComplete(page) // Make sure nothing is auto-saving before next steps
await page.goto(autosaveURL.create)
// gets redirected from /create to /slug/id due to autosave
await page.waitForURL(new RegExp(`${autosaveURL.edit('')}`))
await waitForAutoSaveToComplete(page) // Make sure nothing is auto-saving before next steps
await wait(500)
await expect(page.locator('#field-title')).toBeEnabled()