fix(next): ensures req.locale is populated before running access control (#10533)

Fixes #10529. The `req.locale` property within collection and global
access control functions does not reflect the current locale. This was
because we were attaching the locale to the req only _after_ running
`payload.auth`, which attempts to get access control without a
fully-formed req. The fix is to first authenticate the user using the
`executeAuthStrategies` operation directly, then determine the request
locale with that user, and finally get access results with the proper
locale.
This commit is contained in:
Jacob Fletcher
2025-01-13 10:33:27 -05:00
committed by GitHub
parent 6b051bd59e
commit afcc970e36
7 changed files with 90 additions and 35 deletions

View File

@@ -7,7 +7,6 @@ import * as qs from 'qs-esm'
import type { Args } from './types.js'
import { getRequestLocale } from '../getRequestLocale.js'
import { initReq } from '../initReq.js'
import { getRouteInfo } from './handleAdminPage.js'
import { handleAuthRedirect } from './handleAuthRedirect.js'
@@ -32,7 +31,7 @@ export const initPage = async ({
const cookies = parseCookies(headers)
const { permissions, req } = await initReq(payload.config, {
const { locale, permissions, req } = await initReq(payload.config, {
fallbackLocale: false,
req: {
headers,
@@ -58,12 +57,6 @@ export const initPage = async ({
[],
)
const locale = await getRequestLocale({
req,
})
req.locale = locale?.code
const visibleEntities: VisibleEntities = {
collections: collections
.map(({ slug, admin: { hidden } }) =>

View File

@@ -1,12 +1,22 @@
import type { I18n, I18nClient } from '@payloadcms/translations'
import type { PayloadRequest, SanitizedConfig, SanitizedPermissions } from 'payload'
import type { Locale, PayloadRequest, SanitizedConfig, SanitizedPermissions } from 'payload'
import { initI18n } from '@payloadcms/translations'
import { headers as getHeaders } from 'next/headers.js'
import { createLocalReq, getPayload, getRequestLanguage, parseCookies } from 'payload'
import {
createLocalReq,
executeAuthStrategies,
getAccessResults,
getPayload,
getRequestLanguage,
parseCookies,
} from 'payload'
import { cache } from 'react'
import { getRequestLocale } from './getRequestLocale.js'
type Result = {
locale?: Locale
permissions: SanitizedPermissions
req: PayloadRequest
}
@@ -33,7 +43,14 @@ export const initReq = cache(async function (
language: languageCode,
})
const { permissions, user } = await payload.auth({ headers })
/**
* Cannot simply call `payload.auth` here, as we need the user to get the locale, and we need the locale to get the access results
* I.e. the `payload.auth` function would call `getAccessResults` without a fully-formed `req` object
*/
const { responseHeaders, user } = await executeAuthStrategies({
headers,
payload,
})
const { req: reqOverrides, ...optionsOverrides } = overrides || {}
@@ -43,6 +60,7 @@ export const initReq = cache(async function (
headers,
host: headers.get('host'),
i18n: i18n as I18n,
responseHeaders,
url: `${payload.config.serverURL}`,
user,
...(reqOverrides || {}),
@@ -52,7 +70,18 @@ export const initReq = cache(async function (
payload,
)
const locale = await getRequestLocale({
req,
})
req.locale = locale?.code
const permissions = await getAccessResults({
req,
})
return {
locale,
permissions,
req,
}

View File

@@ -2,6 +2,8 @@ import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
import type { CollectionConfig } from 'payload'
import type { LocalizedPost } from './payload-types.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
@@ -18,6 +20,7 @@ import { RichTextCollection } from './collections/RichText/index.js'
import { Tab } from './collections/Tab/index.js'
import {
blocksWithLocalizedSameName,
cannotCreateDefaultLocale,
defaultLocale,
englishTitle,
hungarianLocale,
@@ -42,7 +45,7 @@ export type LocalizedPostAllLocale = {
}
} & LocalizedPost
const openAccess = {
const openAccess: CollectionConfig['access'] = {
create: () => true,
delete: () => true,
read: () => true,
@@ -258,14 +261,14 @@ export default buildConfigWithDefaults({
// Relation multiple relationTo
{
name: 'localizedRelationMultiRelationTo',
relationTo: [localizedPostsSlug, 'dummy'],
relationTo: [localizedPostsSlug, cannotCreateDefaultLocale],
type: 'relationship',
},
// Relation multiple relationTo hasMany
{
name: 'localizedRelationMultiRelationToHasMany',
hasMany: true,
relationTo: [localizedPostsSlug, 'dummy'],
relationTo: [localizedPostsSlug, cannotCreateDefaultLocale],
type: 'relationship',
},
],
@@ -289,14 +292,14 @@ export default buildConfigWithDefaults({
{
name: 'relationMultiRelationTo',
localized: true,
relationTo: [localizedPostsSlug, 'dummy'],
relationTo: [localizedPostsSlug, cannotCreateDefaultLocale],
type: 'relationship',
},
{
name: 'relationMultiRelationToHasMany',
hasMany: true,
localized: true,
relationTo: [localizedPostsSlug, 'dummy'],
relationTo: [localizedPostsSlug, cannotCreateDefaultLocale],
type: 'relationship',
},
{
@@ -317,14 +320,17 @@ export default buildConfigWithDefaults({
slug: relationshipLocalizedSlug,
},
{
access: openAccess,
access: {
...openAccess,
create: ({ req }) => req.locale !== defaultLocale,
},
fields: [
{
name: 'name',
type: 'text',
},
],
slug: 'dummy',
slug: cannotCreateDefaultLocale,
},
NestedToArrayAndBlock,
Group,

View File

@@ -36,6 +36,7 @@ import { navigateToDoc } from 'helpers/e2e/navigateToDoc.js'
import { upsertPrefs } from 'helpers/e2e/upsertPrefs.js'
import { RESTClient } from 'helpers/rest.js'
import { GeneratedTypes } from 'helpers/sdk/types.js'
import { wait } from 'payload/shared'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -52,6 +53,7 @@ const { beforeAll, beforeEach, describe, afterEach } = test
let url: AdminUrlUtil
let urlWithRequiredLocalizedFields: AdminUrlUtil
let urlRelationshipLocalized: AdminUrlUtil
let urlCannotCreateDefaultLocale: AdminUrlUtil
const title = 'english title'
const spanishTitle = 'spanish title'
@@ -74,6 +76,7 @@ describe('Localization', () => {
urlRelationshipLocalized = new AdminUrlUtil(serverURL, relationshipLocalizedSlug)
richTextURL = new AdminUrlUtil(serverURL, richTextSlug)
urlWithRequiredLocalizedFields = new AdminUrlUtil(serverURL, withRequiredLocalizedFields)
urlCannotCreateDefaultLocale = new AdminUrlUtil(serverURL, 'cannot-create-default-locale')
context = await browser.newContext()
page = await context.newPage()
@@ -122,6 +125,29 @@ describe('Localization', () => {
})
})
describe('access control', () => {
test('should have req.locale within access control', async () => {
await changeLocale(page, defaultLocale)
await page.goto(urlCannotCreateDefaultLocale.list)
const createNewButtonLocator =
'.collection-list a[href="/admin/collections/cannot-create-default-locale/create"]'
await expect(page.locator(createNewButtonLocator)).not.toBeVisible()
await changeLocale(page, spanishLocale)
await expect(page.locator(createNewButtonLocator).first()).toBeVisible()
await page.goto(urlCannotCreateDefaultLocale.create)
await expect(page.locator('#field-name')).toBeVisible()
await changeLocale(page, defaultLocale)
await expect(
page.locator('h1', {
hasText: 'Unauthorized',
}),
).toBeVisible()
})
})
describe('localized text', () => {
test('create english post, switch to spanish', async () => {
await changeLocale(page, defaultLocale)

View File

@@ -22,7 +22,7 @@ export interface Config {
'localized-required': LocalizedRequired;
'with-localized-relationship': WithLocalizedRelationship;
'relationship-localized': RelationshipLocalized;
dummy: Dummy;
'cannot-create-default-locale': CannotCreateDefaultLocale;
nested: Nested;
groups: Group;
tabs: Tab;
@@ -46,7 +46,7 @@ export interface Config {
'localized-required': LocalizedRequiredSelect<false> | LocalizedRequiredSelect<true>;
'with-localized-relationship': WithLocalizedRelationshipSelect<false> | WithLocalizedRelationshipSelect<true>;
'relationship-localized': RelationshipLocalizedSelect<false> | RelationshipLocalizedSelect<true>;
dummy: DummySelect<false> | DummySelect<true>;
'cannot-create-default-locale': CannotCreateDefaultLocaleSelect<false> | CannotCreateDefaultLocaleSelect<true>;
nested: NestedSelect<false> | NestedSelect<true>;
groups: GroupsSelect<false> | GroupsSelect<true>;
tabs: TabsSelect<false> | TabsSelect<true>;
@@ -378,8 +378,8 @@ export interface WithLocalizedRelationship {
value: string | LocalizedPost;
} | null)
| ({
relationTo: 'dummy';
value: string | Dummy;
relationTo: 'cannot-create-default-locale';
value: string | CannotCreateDefaultLocale;
} | null);
localizedRelationMultiRelationToHasMany?:
| (
@@ -388,8 +388,8 @@ export interface WithLocalizedRelationship {
value: string | LocalizedPost;
}
| {
relationTo: 'dummy';
value: string | Dummy;
relationTo: 'cannot-create-default-locale';
value: string | CannotCreateDefaultLocale;
}
)[]
| null;
@@ -398,9 +398,9 @@ export interface WithLocalizedRelationship {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "dummy".
* via the `definition` "cannot-create-default-locale".
*/
export interface Dummy {
export interface CannotCreateDefaultLocale {
id: string;
name?: string | null;
updatedAt: string;
@@ -420,8 +420,8 @@ export interface RelationshipLocalized {
value: string | LocalizedPost;
} | null)
| ({
relationTo: 'dummy';
value: string | Dummy;
relationTo: 'cannot-create-default-locale';
value: string | CannotCreateDefaultLocale;
} | null);
relationMultiRelationToHasMany?:
| (
@@ -430,8 +430,8 @@ export interface RelationshipLocalized {
value: string | LocalizedPost;
}
| {
relationTo: 'dummy';
value: string | Dummy;
relationTo: 'cannot-create-default-locale';
value: string | CannotCreateDefaultLocale;
}
)[]
| null;
@@ -668,8 +668,8 @@ export interface PayloadLockedDocument {
value: string | RelationshipLocalized;
} | null)
| ({
relationTo: 'dummy';
value: string | Dummy;
relationTo: 'cannot-create-default-locale';
value: string | CannotCreateDefaultLocale;
} | null)
| ({
relationTo: 'nested';
@@ -1027,9 +1027,9 @@ export interface RelationshipLocalizedSelect<T extends boolean = true> {
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "dummy_select".
* via the `definition` "cannot-create-default-locale_select".
*/
export interface DummySelect<T extends boolean = true> {
export interface CannotCreateDefaultLocaleSelect<T extends boolean = true> {
name?: T;
updatedAt?: T;
createdAt?: T;

View File

@@ -18,3 +18,4 @@ export const withRequiredLocalizedFields = 'localized-required'
export const localizedSortSlug = 'localized-sort'
export const usersSlug = 'users'
export const blocksWithLocalizedSameName = 'blocks-same-name'
export const cannotCreateDefaultLocale = 'cannot-create-default-locale'

View File

@@ -28,7 +28,7 @@
}
],
"paths": {
"@payload-config": ["./test/_community/config.ts"],
"@payload-config": ["./test/localization/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],