From e905675a05e7868916c500d255e7c2c0e04bddce Mon Sep 17 00:00:00 2001 From: Jarrod Flesch <30633324+JarrodMFlesch@users.noreply.github.com> Date: Wed, 7 Aug 2024 11:10:53 -0400 Subject: [PATCH] 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` --- .../next/src/elements/Nav/index.client.tsx | 20 ++++--- packages/next/src/elements/Nav/index.scss | 7 --- packages/next/src/layouts/Root/index.tsx | 21 +++++++- packages/next/src/views/Account/index.tsx | 4 +- packages/next/src/views/Dashboard/index.tsx | 3 +- packages/next/src/views/Document/index.tsx | 5 +- packages/next/src/views/List/index.tsx | 4 +- packages/next/src/views/NotFound/index.tsx | 29 +++++----- .../fields/Relationship/index.tsx | 2 +- packages/next/src/views/Version/index.tsx | 53 ++++++++++++++----- .../src/views/Versions/getLatestVersion.ts | 22 +++++++- packages/next/src/views/Versions/index.tsx | 31 ++++++++--- .../elements/HydrateAuthProvider/index.tsx | 27 ++++++++++ .../src/elements/HydrateClientUser/index.tsx | 21 -------- packages/ui/src/exports/client/index.ts | 2 +- packages/ui/src/providers/Auth/index.tsx | 15 ++++-- packages/ui/src/providers/Root/index.tsx | 8 ++- test/auth/e2e.spec.ts | 2 +- test/versions/e2e.spec.ts | 23 ++++---- 19 files changed, 190 insertions(+), 109 deletions(-) create mode 100644 packages/ui/src/elements/HydrateAuthProvider/index.tsx delete mode 100644 packages/ui/src/elements/HydrateClientUser/index.tsx diff --git a/packages/next/src/elements/Nav/index.client.tsx b/packages/next/src/elements/Nav/index.client.tsx index 591537de8d..ff5b2382e7 100644 --- a/packages/next/src/elements/Nav/index.client.tsx +++ b/packages/next/src/elements/Nav/index.client.tsx @@ -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 ( { key={i} tabIndex={!navOpen ? -1 : undefined} > - - - + {activeCollection && ( + + + + )} {entityLabel} ) diff --git a/packages/next/src/elements/Nav/index.scss b/packages/next/src/elements/Nav/index.scss index 577e260780..3d8fda8729 100644 --- a/packages/next/src/elements/Nav/index.scss +++ b/packages/next/src/elements/Nav/index.scss @@ -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; diff --git a/packages/next/src/layouts/Root/index.tsx b/packages/next/src/layouts/Root/index.tsx index 9b3c834197..7b980281fe 100644 --- a/packages/next/src/layouts/Root/index.tsx +++ b/packages/next/src/layouts/Root/index.tsx @@ -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} diff --git a/packages/next/src/views/Account/index.tsx b/packages/next/src/views/Account/index.tsx index 842d3e6edc..ad797dabc3 100644 --- a/packages/next/src/views/Account/index.tsx +++ b/packages/next/src/views/Account/index.tsx @@ -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 = async ({ i18n={i18n} permissions={permissions} /> - + = ({ initPageResult, params, se return ( - = async ({ 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 = 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) */} - = async ({ return ( - + - - - - - + + + ) } diff --git a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx index bfcfef34b5..33037d9e45 100644 --- a/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx +++ b/packages/next/src/views/Version/RenderFieldsToDiff/fields/Relationship/index.tsx @@ -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 diff --git a/packages/next/src/views/Version/index.tsx b/packages/next/src/views/Version/index.tsx index 2037c1a928..8957b3cdc8 100644 --- a/packages/next/src/views/Version/index.tsx +++ b/packages/next/src/views/Version/index.tsx @@ -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 ( { + 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 } } diff --git a/packages/next/src/views/Versions/index.tsx b/packages/next/src/views/Versions/index.tsx index 3dcde7124a..376d408391 100644 --- a/packages/next/src/views/Versions/index.tsx +++ b/packages/next/src/views/Versions/index.tsx @@ -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 diff --git a/packages/ui/src/elements/HydrateAuthProvider/index.tsx b/packages/ui/src/elements/HydrateAuthProvider/index.tsx new file mode 100644 index 0000000000..9967e7c43b --- /dev/null +++ b/packages/ui/src/elements/HydrateAuthProvider/index.tsx @@ -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 +} diff --git a/packages/ui/src/elements/HydrateClientUser/index.tsx b/packages/ui/src/elements/HydrateClientUser/index.tsx deleted file mode 100644 index e48a8d6c73..0000000000 --- a/packages/ui/src/elements/HydrateClientUser/index.tsx +++ /dev/null @@ -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 -} diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 8b7be0585b..8318ce9b36 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -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' diff --git a/packages/ui/src/providers/Auth/index.tsx b/packages/ui/src/providers/Auth/index.tsx index 2c79e35573..5c45abb318 100644 --- a/packages/ui/src/providers/Auth/index.tsx +++ b/packages/ui/src/providers/Auth/index.tsx @@ -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() +type Props = { + children: React.ReactNode + permissions?: Permissions + user?: ClientUser | null +} +export function AuthProvider({ + children, + permissions: initialPermissions, + user: initialUser, +}: Props) { + const [user, setUser] = useState(initialUser) const [tokenInMemory, setTokenInMemory] = useState() const [tokenExpiration, setTokenExpiration] = useState() const pathname = usePathname() @@ -51,7 +60,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children serverURL, } = config - const [permissions, setPermissions] = useState() + const [permissions, setPermissions] = useState(initialPermissions) const { i18n } = useTranslation() const { closeAllModals, openModal } = useModal() diff --git a/packages/ui/src/providers/Root/index.tsx b/packages/ui/src/providers/Root/index.tsx index 5d5ba9bfab..db09d00a28 100644 --- a/packages/ui/src/providers/Root/index.tsx +++ b/packages/ui/src/providers/Root/index.tsx @@ -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 theme: Theme translations: I18nClient['translations'] + user: User | null } export const RootProvider: React.FC = ({ @@ -51,9 +53,11 @@ export const RootProvider: React.FC = ({ fallbackLang, languageCode, languageOptions, + permissions, switchLanguageServerAction, theme, translations, + user, }) => { return ( @@ -81,7 +85,7 @@ export const RootProvider: React.FC = ({ - + diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index c37d44cc1d..db3c7f2aad 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -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!') diff --git a/test/versions/e2e.spec.ts b/test/versions/e2e.spec.ts index e92720b460..5e84aa1124 100644 --- a/test/versions/e2e.spec.ts +++ b/test/versions/e2e.spec.ts @@ -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({ 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()