fix(ui): invalid permissions passed to group and named tab sub-fields (#9366)

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

This fixes the following issues that caused fields to be either hidden,
or incorrectly set to readOnly in certain configurations:
- In some cases, permissions were sanitized incorrectly. This PR
rewrites the sanitizePermissions function and adds new unit tests
- after a document save, the client was receiving unsanitized
permissions. Moving the sanitization logic to the endpoint fixes this
- Various incorrect handling of permissions in our form state endpoints
/ RenderFields
This commit is contained in:
Alessio Gravili
2024-11-20 13:03:35 -07:00
committed by GitHub
parent 5db7e1e864
commit c67291d538
23 changed files with 2051 additions and 284 deletions

View File

@@ -0,0 +1,126 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const Regression1: CollectionConfig = {
slug: 'regression1',
access: {
create: () => false,
read: () => true,
},
fields: [
{
name: 'group1',
type: 'group',
fields: [
{
name: 'richText1',
type: 'richText',
editor: lexicalEditor(),
},
{
name: 'text',
type: 'text',
},
],
},
{
type: 'tabs',
tabs: [
{
name: 'tab1',
fields: [
{
name: 'richText2',
type: 'richText',
editor: lexicalEditor(),
},
{
name: 'blocks2',
type: 'blocks',
blocks: [
{
slug: 'myBlock',
fields: [
{
name: 'richText3',
type: 'richText',
editor: lexicalEditor(),
},
],
},
],
},
],
},
{
label: 'tab2',
fields: [
{
name: 'richText4',
type: 'richText',
editor: lexicalEditor(),
},
{
name: 'blocks3',
type: 'blocks',
blocks: [
{
slug: 'myBlock2',
fields: [
{
name: 'richText5',
type: 'richText',
editor: lexicalEditor(),
},
],
},
],
},
],
},
],
},
{
name: 'array',
type: 'array',
fields: [
{
name: 'art',
type: 'richText',
editor: lexicalEditor(),
},
],
},
{
name: 'arrayWithAccessFalse',
type: 'array',
access: {
update: () => false,
},
fields: [
{
name: 'richText6',
type: 'richText',
editor: lexicalEditor(),
},
],
},
{
name: 'blocks',
type: 'blocks',
blocks: [
{
slug: 'myBlock3',
fields: [
{
name: 'richText7',
type: 'richText',
editor: lexicalEditor(),
},
],
},
],
},
],
}

View File

@@ -0,0 +1,38 @@
import type { CollectionConfig } from 'payload'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
export const Regression2: CollectionConfig = {
slug: 'regression2',
fields: [
{
name: 'group',
type: 'group',
fields: [
{
name: 'richText1',
type: 'richText',
editor: lexicalEditor(),
},
{
name: 'text',
type: 'text',
},
],
},
{
name: 'array',
type: 'array',
access: {
update: () => false,
},
fields: [
{
name: 'richText2',
type: 'richText',
editor: lexicalEditor(),
},
],
},
],
}

View File

@@ -8,7 +8,10 @@ import type { Config, User } from './payload-types.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { textToLexicalJSON } from '../fields/collections/LexicalLocalized/textToLexicalJSON.js'
import { Disabled } from './collections/Disabled/index.js'
import { Regression1 } from './collections/Regression-1/index.js'
import { Regression2 } from './collections/Regression-2/index.js'
import { RichText } from './collections/RichText/index.js'
import {
createNotUpdateCollectionSlug,
@@ -508,6 +511,8 @@ export default buildConfigWithDefaults({
},
Disabled,
RichText,
Regression1,
Regression2,
],
globals: [
{
@@ -645,6 +650,58 @@ export default buildConfigWithDefaults({
name: 'dev@payloadcms.com',
},
})
await payload.create({
collection: 'regression1',
data: {
richText4: textToLexicalJSON({ text: 'Text1' }),
array: [{ art: textToLexicalJSON({ text: 'Text2' }) }],
arrayWithAccessFalse: [{ richText6: textToLexicalJSON({ text: 'Text3' }) }],
group1: {
text: 'Text4',
richText1: textToLexicalJSON({ text: 'Text5' }),
},
blocks: [
{
blockType: 'myBlock3',
richText7: textToLexicalJSON({ text: 'Text6' }),
blockName: 'My Block 1',
},
],
blocks3: [
{
blockType: 'myBlock2',
richText5: textToLexicalJSON({ text: 'Text7' }),
blockName: 'My Block 2',
},
],
tab1: {
richText2: textToLexicalJSON({ text: 'Text8' }),
blocks2: [
{
blockType: 'myBlock',
richText3: textToLexicalJSON({ text: 'Text9' }),
blockName: 'My Block 3',
},
],
},
},
})
await payload.create({
collection: 'regression2',
data: {
array: [
{
richText2: textToLexicalJSON({ text: 'Text1' }),
},
],
group: {
text: 'Text2',
richText1: textToLexicalJSON({ text: 'Text3' }),
},
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),

View File

@@ -169,6 +169,132 @@ describe('access control', () => {
}),
).toHaveCount(1)
})
const ensureRegression1FieldsHaveCorrectAccess = async () => {
await expect(
page.locator('#field-group1 .rich-text-lexical .ContentEditable__root'),
).toBeVisible()
// Wait until the contenteditable is editable
await expect(
page.locator('#field-group1 .rich-text-lexical .ContentEditable__root'),
).toBeEditable()
await expect(async () => {
const isAttached = page.locator('#field-group1 .rich-text-lexical--read-only')
await expect(isAttached).toBeHidden()
}).toPass({ timeout: 10000, intervals: [100] })
await expect(page.locator('#field-group1 #field-group1__text')).toBeEnabled()
// Click on button with text Tab1
await page.locator('.tabs-field__tab-button').getByText('Tab1').click()
await expect(
page.locator('.tabs-field__tab .rich-text-lexical .ContentEditable__root').first(),
).toBeVisible()
await expect(
page.locator('.tabs-field__tab .rich-text-lexical--read-only').first(),
).not.toBeAttached()
await expect(
page.locator(
'.tabs-field__tab #field-tab1__blocks2 .rich-text-lexical .ContentEditable__root',
),
).toBeVisible()
await expect(
page.locator('.tabs-field__tab #field-tab1__blocks2 .rich-text-lexical--read-only'),
).not.toBeAttached()
await expect(
page.locator('#field-array #array-row-0 .rich-text-lexical .ContentEditable__root'),
).toBeVisible()
await expect(
page.locator('#field-array #array-row-0 .rich-text-lexical--read-only'),
).not.toBeAttached()
await expect(
page.locator(
'#field-arrayWithAccessFalse #arrayWithAccessFalse-row-0 .rich-text-lexical .ContentEditable__root',
),
).toBeVisible()
await expect(
page.locator(
'#field-arrayWithAccessFalse #arrayWithAccessFalse-row-0 .rich-text-lexical--read-only',
),
).toBeVisible()
await expect(
page.locator('#field-blocks .rich-text-lexical .ContentEditable__root'),
).toBeVisible()
await expect(page.locator('#field-blocks.rich-text-lexical--read-only')).not.toBeAttached()
}
/**
* This reproduces a bug where certain fields were incorrectly marked as read-only
*/
// eslint-disable-next-line playwright/expect-expect
test('ensure complex collection config fields show up in correct read-only state', async () => {
const regression1URL = new AdminUrlUtil(serverURL, 'regression1')
await page.goto(regression1URL.list)
// Click on first card
await page.locator('.cell-id a').first().click()
// wait for url
await page.waitForURL(`**/collections/regression1/**`)
await ensureRegression1FieldsHaveCorrectAccess()
// Edit any field
await page.locator('#field-group1__text').fill('test!')
// Save the doc
await saveDocAndAssert(page)
await wait(1000)
// Ensure fields still have the correct readOnly state. When saving the document, permissions are re-evaluated
await ensureRegression1FieldsHaveCorrectAccess()
})
const ensureRegression2FieldsHaveCorrectAccess = async () => {
await expect(
page.locator('#field-group .rich-text-lexical .ContentEditable__root'),
).toBeVisible()
// Wait until the contenteditable is editable
await expect(
page.locator('#field-group .rich-text-lexical .ContentEditable__root'),
).toBeEditable()
await expect(async () => {
const isAttached = page.locator('#field-group .rich-text-lexical--read-only')
await expect(isAttached).toBeHidden()
}).toPass({ timeout: 10000, intervals: [100] })
await expect(page.locator('#field-group #field-group__text')).toBeEnabled()
await expect(
page.locator('#field-array #array-row-0 .rich-text-lexical .ContentEditable__root'),
).toBeVisible()
await expect(
page.locator('#field-array #array-row-0 .rich-text-lexical--read-only'),
).toBeVisible() // => is read-only
}
/**
* This reproduces a bug where certain fields were incorrectly marked as read-only
*/
// eslint-disable-next-line playwright/expect-expect
test('ensure complex collection config fields show up in correct read-only state 2', async () => {
const regression2URL = new AdminUrlUtil(serverURL, 'regression2')
await page.goto(regression2URL.list)
// Click on first card
await page.locator('.cell-id a').first().click()
// wait for url
await page.waitForURL(`**/collections/regression2/**`)
await ensureRegression2FieldsHaveCorrectAccess()
// Edit any field
await page.locator('#field-group__text').fill('test!')
// Save the doc
await saveDocAndAssert(page)
await wait(1000)
// Ensure fields still have the correct readOnly state. When saving the document, permissions are re-evaluated
await ensureRegression2FieldsHaveCorrectAccess()
})
})
describe('collection — fully restricted', () => {

View File

@@ -30,6 +30,8 @@ export interface Config {
'hidden-access-count': HiddenAccessCount;
disabled: Disabled;
'rich-text': RichText;
regression1: Regression1;
regression2: Regression2;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -54,6 +56,8 @@ export interface Config {
'hidden-access-count': HiddenAccessCountSelect<false> | HiddenAccessCountSelect<true>;
disabled: DisabledSelect<false> | DisabledSelect<true>;
'rich-text': RichTextSelect<false> | RichTextSelect<true>;
regression1: Regression1Select<false> | Regression1Select<true>;
regression2: Regression2Select<false> | Regression2Select<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -83,9 +87,9 @@ export interface Config {
| (NonAdminUser & {
collection: 'non-admin-user';
});
jobs?: {
jobs: {
tasks: unknown;
workflows?: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
@@ -383,6 +387,218 @@ export interface RichText {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "regression1".
*/
export interface Regression1 {
id: string;
group1?: {
richText1?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
text?: string | null;
};
tab1?: {
richText2?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
blocks2?:
| {
richText3?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'myBlock';
}[]
| null;
};
richText4?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
blocks3?:
| {
richText5?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'myBlock2';
}[]
| null;
array?:
| {
art?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
}[]
| null;
arrayWithAccessFalse?:
| {
richText6?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
}[]
| null;
blocks?:
| {
richText7?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
blockName?: string | null;
blockType: 'myBlock3';
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "regression2".
*/
export interface Regression2 {
id: string;
group?: {
richText1?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
text?: string | null;
};
array?:
| {
richText2?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -461,6 +677,14 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'rich-text';
value: string | RichText;
} | null)
| ({
relationTo: 'regression1';
value: string | Regression1;
} | null)
| ({
relationTo: 'regression2';
value: string | Regression2;
} | null);
globalSlug?: string | null;
user:
@@ -750,6 +974,91 @@ export interface RichTextSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "regression1_select".
*/
export interface Regression1Select<T extends boolean = true> {
group1?:
| T
| {
richText1?: T;
text?: T;
};
tab1?:
| T
| {
richText2?: T;
blocks2?:
| T
| {
myBlock?:
| T
| {
richText3?: T;
id?: T;
blockName?: T;
};
};
};
richText4?: T;
blocks3?:
| T
| {
myBlock2?:
| T
| {
richText5?: T;
id?: T;
blockName?: T;
};
};
array?:
| T
| {
art?: T;
id?: T;
};
arrayWithAccessFalse?:
| T
| {
richText6?: T;
id?: T;
};
blocks?:
| T
| {
myBlock3?:
| T
| {
richText7?: T;
id?: T;
blockName?: T;
};
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "regression2_select".
*/
export interface Regression2Select<T extends boolean = true> {
group?:
| T
| {
richText1?: T;
text?: T;
};
array?:
| T
| {
richText2?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".