diff --git a/packages/graphql/src/resolvers/collections/docAccess.ts b/packages/graphql/src/resolvers/collections/docAccess.ts index 4909b53e15e..6a0d2d55f62 100644 --- a/packages/graphql/src/resolvers/collections/docAccess.ts +++ b/packages/graphql/src/resolvers/collections/docAccess.ts @@ -1,4 +1,9 @@ -import type { Collection, CollectionPermission, GlobalPermission, PayloadRequest } from 'payload' +import type { + Collection, + PayloadRequest, + SanitizedCollectionPermission, + SanitizedGlobalPermission, +} from 'payload' import { docAccessOperation, isolateObjectProperty } from 'payload' @@ -12,7 +17,7 @@ export type Resolver = ( context: { req: PayloadRequest }, -) => Promise +) => Promise export function docAccessResolver(collection: Collection): Resolver { async function resolver(_, args, context: Context) { diff --git a/packages/graphql/src/resolvers/globals/docAccess.ts b/packages/graphql/src/resolvers/globals/docAccess.ts index 72d13794841..0a8ca8d927f 100644 --- a/packages/graphql/src/resolvers/globals/docAccess.ts +++ b/packages/graphql/src/resolvers/globals/docAccess.ts @@ -1,8 +1,8 @@ import type { - CollectionPermission, - GlobalPermission, PayloadRequest, + SanitizedCollectionPermission, SanitizedGlobalConfig, + SanitizedGlobalPermission, } from 'payload' import { docAccessOperationGlobal, isolateObjectProperty } from 'payload' @@ -14,7 +14,7 @@ export type Resolver = ( context: { req: PayloadRequest }, -) => Promise +) => Promise export function docAccessResolver(global: SanitizedGlobalConfig): Resolver { async function resolver(_, context: Context) { diff --git a/packages/next/src/views/Document/getDocumentPermissions.tsx b/packages/next/src/views/Document/getDocumentPermissions.tsx index eccdbf900db..75a006e1356 100644 --- a/packages/next/src/views/Document/getDocumentPermissions.tsx +++ b/packages/next/src/views/Document/getDocumentPermissions.tsx @@ -1,6 +1,5 @@ import type { Data, - DocumentPermissions, PayloadRequest, SanitizedCollectionConfig, SanitizedDocumentPermissions, @@ -11,7 +10,7 @@ import { hasSavePermission as getHasSavePermission, isEditing as getIsEditing, } from '@payloadcms/ui/shared' -import { docAccessOperation, docAccessOperationGlobal, sanitizePermissions } from 'payload' +import { docAccessOperation, docAccessOperationGlobal } from 'payload' export const getDocumentPermissions = async (args: { collectionConfig?: SanitizedCollectionConfig @@ -26,7 +25,7 @@ export const getDocumentPermissions = async (args: { }> => { const { id, collectionConfig, data = {}, globalConfig, req } = args - let docPermissions: DocumentPermissions + let docPermissions: SanitizedDocumentPermissions let hasPublishPermission = false if (collectionConfig) { @@ -58,7 +57,7 @@ export const getDocumentPermissions = async (args: { _status: 'published', }, }, - }).then(({ update }) => update?.permission) + }).then((permissions) => permissions.update) } } catch (error) { req.payload.logger.error(error) @@ -85,20 +84,16 @@ export const getDocumentPermissions = async (args: { _status: 'published', }, }, - }).then(({ update }) => update?.permission) + }).then((permissions) => permissions.update) } } catch (error) { req.payload.logger.error(error) } } - // TODO: do this in a better way. Only doing this bc this is how the fn was written (mutates the original object) - const sanitizedDocPermissions = { ...docPermissions } as any as SanitizedDocumentPermissions - sanitizePermissions(sanitizedDocPermissions) - const hasSavePermission = getHasSavePermission({ collectionSlug: collectionConfig?.slug, - docPermissions: sanitizedDocPermissions, + docPermissions, globalSlug: globalConfig?.slug, isEditing: getIsEditing({ id, @@ -108,7 +103,7 @@ export const getDocumentPermissions = async (args: { }) return { - docPermissions: sanitizedDocPermissions, + docPermissions, hasPublishPermission, hasSavePermission, } diff --git a/packages/payload/src/auth/types.ts b/packages/payload/src/auth/types.ts index d994f46a94f..371261c6820 100644 --- a/packages/payload/src/auth/types.ts +++ b/packages/payload/src/auth/types.ts @@ -11,61 +11,61 @@ export type Permission = { where?: Where } -export type FieldPermissions = { - blocks?: { - [blockSlug: string]: { - create: { - permission: boolean - } - fields: { - [fieldName: string]: FieldPermissions - } - read: { - permission: boolean - } - update: { - permission: boolean - } +export type FieldsPermissions = { + [fieldName: string]: FieldPermissions +} + +export type BlockPermissions = { + create: Permission + fields: FieldsPermissions + read: Permission + update: Permission +} + +export type SanitizedBlockPermissions = + | { + fields: SanitizedFieldsPermissions } - } - create: { - permission: boolean - } - fields?: { - [fieldName: string]: FieldPermissions - } - read: { - permission: boolean - } - update: { - permission: boolean - } + | true + +export type BlocksPermissions = { + [blockSlug: string]: BlockPermissions +} + +export type SanitizedBlocksPermissions = + | { + [blockSlug: string]: SanitizedBlockPermissions + } + | true + +export type FieldPermissions = { + blocks?: BlocksPermissions + create: Permission + fields?: FieldsPermissions + read: Permission + update: Permission } export type SanitizedFieldPermissions = | { - blocks?: { - [blockSlug: string]: { - fields: { - [fieldName: string]: SanitizedFieldPermissions - } - } - } + blocks?: SanitizedBlocksPermissions create: true - fields?: { - [fieldName: string]: SanitizedFieldPermissions - } + fields?: SanitizedFieldsPermissions read: true update: true } | true +export type SanitizedFieldsPermissions = + | { + [fieldName: string]: SanitizedFieldPermissions + } + | true + export type CollectionPermission = { create: Permission delete: Permission - fields: { - [fieldName: string]: FieldPermissions - } + fields: FieldsPermissions read: Permission readVersions?: Permission update: Permission @@ -74,31 +74,21 @@ export type CollectionPermission = { export type SanitizedCollectionPermission = { create?: true delete?: true - fields: - | { - [fieldName: string]: SanitizedFieldPermissions - } - | true + fields: SanitizedFieldsPermissions read?: true readVersions?: true update?: true } export type GlobalPermission = { - fields: { - [fieldName: string]: FieldPermissions - } + fields: FieldsPermissions read: Permission readVersions?: Permission update: Permission } export type SanitizedGlobalPermission = { - fields: - | { - [fieldName: string]: SanitizedFieldPermissions - } - | true + fields: SanitizedFieldsPermissions read?: true readVersions?: true update?: true @@ -110,7 +100,7 @@ export type SanitizedDocumentPermissions = SanitizedCollectionPermission | Sanit export type Permissions = { canAccessAdmin: boolean - collections: { + collections?: { [collectionSlug: CollectionSlug]: CollectionPermission } globals?: { @@ -121,26 +111,10 @@ export type Permissions = { export type SanitizedPermissions = { canAccessAdmin?: boolean collections?: { - [collectionSlug: string]: { - create?: true - delete?: true - fields: { - [fieldName: string]: SanitizedFieldPermissions - } - read?: true - readVersions?: true - update?: true - } + [collectionSlug: string]: SanitizedCollectionPermission } globals?: { - [globalSlug: string]: { - fields: { - [fieldName: string]: SanitizedFieldPermissions - } - read?: true - readVersions?: true - update?: true - } + [globalSlug: string]: SanitizedGlobalPermission } } diff --git a/packages/payload/src/collections/operations/docAccess.ts b/packages/payload/src/collections/operations/docAccess.ts index 7cade684263..eec1e4b70e7 100644 --- a/packages/payload/src/collections/operations/docAccess.ts +++ b/packages/payload/src/collections/operations/docAccess.ts @@ -1,9 +1,10 @@ -import type { CollectionPermission } from '../../auth/index.js' +import type { CollectionPermission, SanitizedCollectionPermission } from '../../auth/index.js' import type { AllOperations, PayloadRequest } from '../../types/index.js' import type { Collection } from '../config/types.js' import { getEntityPolicies } from '../../utilities/getEntityPolicies.js' import { killTransaction } from '../../utilities/killTransaction.js' +import { sanitizePermissions } from '../../utilities/sanitizePermissions.js' const allOperations: AllOperations[] = ['create', 'read', 'update', 'delete'] @@ -13,7 +14,7 @@ type Arguments = { req: PayloadRequest } -export async function docAccessOperation(args: Arguments): Promise { +export async function docAccessOperation(args: Arguments): Promise { const { id, collection: { config }, @@ -43,7 +44,14 @@ export async function docAccessOperation(args: Arguments): Promise => { +export const docAccessOperation = async (args: Arguments): Promise => { const { globalConfig, req } = args const globalOperations: AllOperations[] = ['read', 'update'] @@ -32,7 +33,14 @@ export const docAccessOperation = async (args: Arguments): Promise { @@ -36,7 +36,12 @@ describe('recursivelySanitizePermissions', () => { }, } - recursivelySanitizePermissions(permissions) + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions, + }, + }) expect(permissions).toStrictEqual({ fields: true, @@ -77,8 +82,12 @@ describe('recursivelySanitizePermissions', () => { }, } - recursivelySanitizePermissions(permissions) - + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions, + }, + }) expect(permissions).toStrictEqual({ create: { permission: true, @@ -88,6 +97,7 @@ describe('recursivelySanitizePermissions', () => { }, }, }, + fields: true, read: true, update: true, readVersions: { @@ -178,8 +188,12 @@ describe('recursivelySanitizePermissions', () => { }, } - recursivelySanitizePermissions(permissions) - + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions, + }, + }) expect(permissions).toStrictEqual({ create: true, delete: true, @@ -266,8 +280,12 @@ describe('recursivelySanitizePermissions', () => { }, } - recursivelySanitizePermissions(permissions) - + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions, + }, + }) expect(permissions).toStrictEqual({ create: true, delete: true, @@ -349,8 +367,12 @@ describe('recursivelySanitizePermissions', () => { }, } - recursivelySanitizePermissions(permissions) - + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions as CollectionPermission, + }, + }) expect(permissions).toStrictEqual({ fields: { arrayOfText: { @@ -432,8 +454,12 @@ describe('recursivelySanitizePermissions', () => { }, } - recursivelySanitizePermissions(permissions) - + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions, + }, + }) expect(permissions).toStrictEqual({ fields: true, create: true, @@ -471,12 +497,1063 @@ describe('recursivelySanitizePermissions', () => { }, } - recursivelySanitizePermissions(permissions) - + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions as CollectionPermission, + }, + }) expect(permissions).toStrictEqual({ fields: true, }) }) + + it('ensure complex permissions are sanitized correctly', () => { + // This tests a bug where the sanitizePermissions function would previously not correctly sanitize + const permissions: Partial = { + fields: { + GR: { + create: { + permission: false, + }, + fields: { + rt: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + aaa: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + tab1: { + fields: { + rt2: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blocks2: { + create: { + permission: false, + }, + blocks: { + myBlock: { + fields: { + art: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blockName: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + rt3: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blocks3: { + create: { + permission: false, + }, + blocks: { + myBlock: { + fields: { + art: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blockName: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + array: { + create: { + permission: false, + }, + fields: { + art: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + arrayWithAccessFalse: { + create: { + permission: false, + }, + fields: { + art: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: false, + }, + }, + id: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: false, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: false, + }, + }, + blocks: { + create: { + permission: false, + }, + blocks: { + myBlock: { + fields: { + art: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blockName: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + updatedAt: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + createdAt: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + delete: { + permission: true, + }, + } + + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions as CollectionPermission, + }, + }) + expect(permissions).toStrictEqual({ + fields: { + GR: { + fields: { + rt: { + read: true, + update: true, + }, + aaa: { + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + tab1: { + fields: { + rt2: { + read: true, + update: true, + }, + blocks2: { + blocks: { + myBlock: { + fields: { + art: { + read: true, + update: true, + }, + id: { + read: true, + update: true, + }, + blockName: { + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + rt3: { + read: true, + update: true, + }, + blocks3: { + blocks: { + myBlock: { + fields: { + art: { + read: true, + update: true, + }, + id: { + read: true, + update: true, + }, + blockName: { + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + array: { + fields: { + art: { + read: true, + update: true, + }, + id: { + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + arrayWithAccessFalse: { + fields: { + art: { + read: true, + }, + id: { + read: true, + }, + }, + read: true, + }, + blocks: { + blocks: { + myBlock: { + fields: { + art: { + read: true, + update: true, + }, + id: { + read: true, + update: true, + }, + blockName: { + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + }, + read: true, + update: true, + }, + updatedAt: { + read: true, + update: true, + }, + createdAt: { + read: true, + update: true, + }, + }, + read: true, + update: true, + delete: true, + }) + }) + + it('ensure complex permissions are sanitized correctly 2', () => { + // This tests a bug where the sanitizePermissions function would previously not correctly sanitize + const permissions: Partial = { + fields: { + GR: { + create: { + permission: true, + }, + fields: { + rt: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + aaa: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + tab1: { + fields: { + rt2: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blocks2: { + create: { + permission: true, + }, + blocks: { + myBlock: { + fields: { + art: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blockName: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + rt3: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + arrayWithAccessFalse: { + create: { + permission: false, + }, + fields: { + art: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: false, + }, + }, + id: { + create: { + permission: false, + }, + read: { + permission: true, + }, + update: { + permission: false, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: false, + }, + }, + blocks: { + create: { + permission: true, + }, + blocks: { + myBlock: { + fields: { + art: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blockName: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + updatedAt: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + createdAt: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + delete: { + permission: true, + }, + } + + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions as CollectionPermission, + }, + }) + expect(permissions).toStrictEqual({ + fields: { + GR: true, + tab1: true, + rt3: true, + arrayWithAccessFalse: { + fields: { + art: { + read: true, + }, + id: { + read: true, + }, + }, + read: true, + }, + blocks: true, + updatedAt: true, + createdAt: true, + }, + create: true, + read: true, + update: true, + delete: true, + }) + }) + + it('ensure complex permissions are sanitized correctly 3', () => { + // This tests a bug where the sanitizePermissions function would previously not correctly sanitize + const permissions: Partial = { + fields: { + GR: { + create: { + permission: true, + }, + fields: { + rt: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + aaa: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + tab1: { + fields: { + rt2: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blocks2: { + create: { + permission: true, + }, + blocks: { + myBlock: { + fields: { + art: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blockName: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + rt3: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blocks: { + create: { + permission: true, + }, + blocks: { + myBlock: { + fields: { + art: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + id: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + blockName: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + updatedAt: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + createdAt: { + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + }, + }, + create: { + permission: true, + }, + read: { + permission: true, + }, + update: { + permission: true, + }, + delete: { + permission: true, + }, + } + + sanitizePermissions({ + canAccessAdmin: true, + collections: { + test: permissions as CollectionPermission, + }, + }) + expect(permissions).toStrictEqual({ + fields: true, + create: true, + read: true, + update: true, + delete: true, + }) + }) }) describe('sanitizePermissions', () => { diff --git a/packages/payload/src/utilities/sanitizePermissions.ts b/packages/payload/src/utilities/sanitizePermissions.ts index 68fb268eb56..8072157318c 100644 --- a/packages/payload/src/utilities/sanitizePermissions.ts +++ b/packages/payload/src/utilities/sanitizePermissions.ts @@ -1,61 +1,147 @@ -import type { Permissions, SanitizedPermissions } from '../auth/types.js' +import type { + CollectionPermission, + FieldPermissions, + FieldsPermissions, + GlobalPermission, + Permissions, + SanitizedBlocksPermissions, + SanitizedFieldPermissions, + SanitizedFieldsPermissions, + SanitizedPermissions, +} from '../auth/types.js' + +function checkAndSanitizeFieldsPermssions(data: FieldsPermissions): boolean { + let allFieldPermissionsTrue = true + for (const key in data) { + if (typeof data[key] === 'object') { + if (!checkAndSanitizePermissions(data[key])) { + allFieldPermissionsTrue = false + } else { + ;(data[key] as unknown as SanitizedFieldPermissions) = true + } + } else if (data[key] !== true) { + allFieldPermissionsTrue = false + } + } -type PermissionObject = { - [key: string]: any + // If all values are true or it's an empty object, return true + return allFieldPermissionsTrue } /** - * Check if all permissions in a FieldPermissions object are true on the condition that no nested blocks or fields are present. + * Check if all permissions in a FieldPermissions, CollectionPermission or GlobalPermission object are true. + * If nested fields or blocks are present, the function will recursively check those as well. */ -function areAllPermissionsTrue(data: PermissionObject): boolean { - if (data.blocks) { - for (const key in data.blocks) { - if (typeof data.blocks[key] === 'object') { - // If any recursive call returns false, the whole function returns false - if (key === 'fields' && !areAllPermissionsTrue(data.blocks[key])) { - return false - } - if (data.blocks[key].fields && !areAllPermissionsTrue(data.blocks[key].fields)) { - return false +function checkAndSanitizePermissions( + data: CollectionPermission | FieldPermissions | GlobalPermission, +): boolean { + /** + * Check blocks permissions + */ + let blocksPermissions = true + if ('blocks' in data && data.blocks) { + for (const blockSlug in data.blocks) { + if (typeof data.blocks[blockSlug] === 'object') { + for (const key in data.blocks[blockSlug]) { + /** + * Check fields in nested blocks + */ + if (key === 'fields') { + if (data.blocks[blockSlug].fields) { + if (!checkAndSanitizeFieldsPermssions(data.blocks[blockSlug].fields)) { + blocksPermissions = false + } else { + ;(data.blocks[blockSlug].fields as unknown as SanitizedFieldsPermissions) = true + } + } + } else { + if (typeof data.blocks[blockSlug][key] === 'object') { + /** + * Check Permissions in nested blocks + */ + if (isPermissionObject(data.blocks[blockSlug][key])) { + if ( + data.blocks[blockSlug][key]['permission'] === true && + !('where' in data.blocks[blockSlug][key]) + ) { + // If the permission is true and there is no where clause, set the key to true + data.blocks[blockSlug][key] = true + continue + } else if ( + data.blocks[blockSlug][key]['permission'] === true && + 'where' in data.blocks[blockSlug][key] + ) { + // otherwise do nothing so we can keep the where clause + blocksPermissions = false + } else { + blocksPermissions = false + data.blocks[blockSlug][key] = false + delete data.blocks[blockSlug][key] + continue + } + } else { + throw new Error('Unexpected object in block permissions') + } + } + } } - } else if (data.blocks[key] !== true) { + } else if (data.blocks[blockSlug] !== true) { // If any value is not true, return false - return false + blocksPermissions = false + delete data.blocks[blockSlug] } } - // If all values are true or it's an empty object, return true - return true + if (blocksPermissions) { + ;(data.blocks as unknown as SanitizedBlocksPermissions) = true + } } + /** + * Check nested Fields permissions + */ + let fieldsPermissions = true if (data.fields) { - for (const key in data.fields) { - if (typeof data.fields[key] === 'object') { - // If any recursive call returns false, the whole function returns false - if (!areAllPermissionsTrue(data.fields[key])) { - return false - } - } else if (data.fields[key] !== true) { - // If any value is not true, return false - return false - } + if (!checkAndSanitizeFieldsPermssions(data.fields)) { + fieldsPermissions = false + } else { + ;(data.fields as unknown as SanitizedFieldsPermissions) = true } - // If all values are true or it's an empty object, return true - return true } + /** + * Check other Permissions objects (e.g. read, write) + */ + let otherPermissions = true for (const key in data) { + if (key === 'fields' || key === 'blocks') { + continue + } if (typeof data[key] === 'object') { - // If any recursive call returns false, the whole function returns false - if (!areAllPermissionsTrue(data[key])) { - return false + if (isPermissionObject(data[key])) { + if (data[key]['permission'] === true && !('where' in data[key])) { + // If the permission is true and there is no where clause, set the key to true + data[key] = true + continue + } else if (data[key]['permission'] === true && 'where' in data[key]) { + // otherwise do nothing so we can keep the where clause + otherPermissions = false + } else { + otherPermissions = false + data[key] = false + delete data[key] + continue + } + } else { + throw new Error('Unexpected object in fields permissions') } } else if (data[key] !== true) { // If any value is not true, return false - return false + otherPermissions = false } } + // If all values are true or it's an empty object, return true - return true + return fieldsPermissions && blocksPermissions && otherPermissions } /** @@ -83,84 +169,27 @@ function cleanEmptyObjects(obj: any): void { }) } -/** - * Recursively resolve permissions in an object. - */ -export function recursivelySanitizePermissions(obj: PermissionObject): void { +export function recursivelySanitizeCollections(obj: Permissions['collections']): void { if (typeof obj !== 'object') { return } - const entries = Object.entries(obj) + const collectionPermissions = Object.values(obj) - for (let i = 0; i < entries.length; i++) { - const [key, value] = entries[i] - // Check if it's a 'fields' key - if (key === 'fields') { - // Check if fields is empty - if (Object.keys(obj[key]).length === 0) { - delete obj[key] - continue - } - // Otherwise set fields to true if all permissions are true - else if (areAllPermissionsTrue(value)) { - obj[key] = true - continue - } - } else if (key === 'blocks') { - // Check if fields is empty - if (Object.keys(obj[key]).length === 0) { - delete obj[key] - continue - } - // Otherwise set fields to true if all permissions are true - else if (areAllPermissionsTrue(value)) { - obj[key] = true - continue - } - } + for (const collectionPermission of collectionPermissions) { + checkAndSanitizePermissions(collectionPermission) + } +} - // Check if the whole object is a permission object - const isFullPermissionObject = Object.keys(value).every( - (subKey) => - subKey !== 'blocks' && - typeof value?.[subKey] === 'object' && - 'permission' in value[subKey] && - !('where' in value[subKey]) && - typeof value[subKey]['permission'] === 'boolean', - ) - - if (isFullPermissionObject) { - if (areAllPermissionsTrue(value)) { - obj[key] = true - continue - } else { - for (const subKey in value) { - if (value[subKey]['permission'] === true && !('where' in value[subKey])) { - value[subKey] = true - continue - } else if (value[subKey]['permission'] === true && 'where' in value[subKey]) { - // do nothing - } else { - delete value[subKey] - continue - } - } - } - } else if (isPermissionObject(value)) { - if (value['permission'] === true && !('where' in value)) { - // If the permission is true and there is no where clause, set the key to true - obj[key] = true - continue - } else if (value['permission'] === true && 'where' in value) { - // otherwise do nothing so we can keep the where clause - } else { - delete obj[key] - continue - } - } else { - recursivelySanitizePermissions(value) - } +export function recursivelySanitizeGlobals(obj: Permissions['globals']): void { + if (typeof obj !== 'object') { + return + } + + const globalPermissions = Object.values(obj) + + for (const globalPermission of globalPermissions) { + checkAndSanitizePermissions(globalPermission) } } @@ -173,11 +202,11 @@ export function sanitizePermissions(data: Permissions): SanitizedPermissions { } if (data.collections) { - recursivelySanitizePermissions(data.collections) + recursivelySanitizeCollections(data.collections) } if (data.globals) { - recursivelySanitizePermissions(data.globals) + recursivelySanitizeGlobals(data.globals) } // Run clean up of empty objects at the end diff --git a/packages/ui/src/fields/Blocks/BlockRow.tsx b/packages/ui/src/fields/Blocks/BlockRow.tsx index a553376d1f9..eedee96032f 100644 --- a/packages/ui/src/fields/Blocks/BlockRow.tsx +++ b/packages/ui/src/fields/Blocks/BlockRow.tsx @@ -1,10 +1,18 @@ 'use client' -import type { ClientBlock, ClientField, Labels, Row, SanitizedFieldPermissions } from 'payload' +import type { + ClientBlock, + ClientField, + Labels, + Row, + SanitizedFieldPermissions, + SanitizedFieldsPermissions, +} from 'payload' import { getTranslation } from '@payloadcms/translations' import React from 'react' import type { UseDraggableSortableReturn } from '../../elements/DraggableSortable/useDraggableSortable/types.js' +import type { RenderFieldsProps } from '../../forms/RenderFields/types.js' import { Collapsible } from '../../elements/Collapsible/index.js' import { ErrorPill } from '../../elements/ErrorPill/index.js' @@ -80,6 +88,19 @@ export const BlockRow: React.FC = ({ .filter(Boolean) .join(' ') + let blockPermissions: RenderFieldsProps['permissions'] = undefined + + if (permissions === true) { + blockPermissions = true + } else { + const permissionsBlockSpecific = permissions?.blocks?.[block.slug] + if (permissionsBlockSpecific === true) { + blockPermissions = true + } else { + blockPermissions = permissionsBlockSpecific?.fields + } + } + return (
= ({ parentIndexPath="" parentPath={path} parentSchemaPath={schemaPath} - permissions={ - permissions === true ? permissions : permissions?.blocks?.[block.slug]?.fields - } + permissions={blockPermissions} readOnly={readOnly} /> diff --git a/packages/ui/src/forms/RenderFields/index.tsx b/packages/ui/src/forms/RenderFields/index.tsx index 93922471a26..09bb11a7705 100644 --- a/packages/ui/src/forms/RenderFields/index.tsx +++ b/packages/ui/src/forms/RenderFields/index.tsx @@ -3,7 +3,7 @@ import { getFieldPaths } from 'payload/shared' import React from 'react' -import type { Props } from './types.js' +import type { RenderFieldsProps } from './types.js' import { RenderIfInViewport } from '../../elements/RenderIfInViewport/index.js' import { useOperation } from '../../providers/Operation/index.js' @@ -12,9 +12,9 @@ import { RenderField } from './RenderField.js' const baseClass = 'render-fields' -export { Props } +export { RenderFieldsProps as Props } -export const RenderFields: React.FC = (props) => { +export const RenderFields: React.FC = (props) => { const { className, fields, @@ -49,10 +49,15 @@ export const RenderFields: React.FC = (props) => { return null } + const parentName = parentPath?.includes('.') + ? parentPath.split('.')[parentPath.split('.').length - 1] + : parentPath + // If the user cannot read the field, then filter it out // This is different from `admin.readOnly` which is executed based on `operation` const hasReadPermission = permissions === true || + permissions?.[parentName] === true || ('name' in field && typeof permissions === 'object' && permissions?.[field.name] && @@ -74,6 +79,7 @@ export const RenderFields: React.FC = (props) => { // If the user does not have access control to begin with, force it to be read-only const hasOperationPermission = permissions === true || + permissions?.[parentName] === true || ('name' in field && typeof permissions === 'object' && permissions?.[field.name] && @@ -102,7 +108,7 @@ export const RenderFields: React.FC = (props) => { parentSchemaPath={parentSchemaPath} path={path} permissions={ - permissions === null || permissions === true + permissions === undefined || permissions === null || permissions === true ? true : 'name' in field ? permissions?.[field.name] diff --git a/packages/ui/src/forms/RenderFields/types.ts b/packages/ui/src/forms/RenderFields/types.ts index f2df6038124..743c9aedb95 100644 --- a/packages/ui/src/forms/RenderFields/types.ts +++ b/packages/ui/src/forms/RenderFields/types.ts @@ -1,6 +1,6 @@ import type { ClientField, SanitizedFieldPermissions } from 'payload' -export type Props = { +export type RenderFieldsProps = { readonly className?: string readonly fields: ClientField[] /** diff --git a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts index 2b8bd0fe6ed..15e27c8ecbe 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/addFieldStatePromise.ts @@ -8,10 +8,12 @@ import type { FormStateWithoutComponents, PayloadRequest, SanitizedFieldPermissions, + SanitizedFieldsPermissions, } from 'payload' import ObjectIdImport from 'bson-objectid' import { + deepCopyObjectSimple, fieldAffectsData, fieldHasSubFields, fieldIsSidebar, @@ -59,14 +61,9 @@ export type AddFieldStatePromiseArgs = { operation: 'create' | 'update' parentIndexPath: string parentPath: string + parentPermissions: SanitizedFieldsPermissions parentSchemaPath: string passesCondition: boolean - permissions: - | { - [fieldName: string]: SanitizedFieldPermissions - } - | null - | SanitizedFieldPermissions preferences: DocumentPreferences previousFormState: FormState renderAllFields: boolean @@ -109,9 +106,9 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom operation, parentIndexPath, parentPath, + parentPermissions, parentSchemaPath, passesCondition, - permissions, preferences, previousFormState, renderAllFields, @@ -135,10 +132,15 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom const isHiddenField = 'hidden' in field && field?.hidden const disabledFromAdmin = field?.admin && 'disabled' in field.admin && field.admin.disabled + let fieldPermissions: SanitizedFieldPermissions = true if (fieldAffectsData(field) && !(isHiddenField || disabledFromAdmin)) { - const fieldPermissions = permissions === true ? permissions : permissions?.[field.name] + fieldPermissions = + parentPermissions === true + ? parentPermissions + : deepCopyObjectSimple(parentPermissions?.[field.name]) - let hasPermission: boolean = fieldPermissions === true || fieldPermissions?.read + let hasPermission: boolean = + fieldPermissions === true || deepCopyObjectSimple(fieldPermissions?.read) if (typeof field?.access?.read === 'function') { hasPermission = await field.access.read({ doc: fullData, req, siblingData: data }) @@ -385,7 +387,9 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom permissions: fieldPermissions === true ? fieldPermissions - : permissions?.[field.name]?.blocks?.[block.slug]?.fields || {}, + : parentPermissions?.[field.name]?.blocks?.[block.slug] === true + ? true + : parentPermissions?.[field.name]?.blocks?.[block.slug]?.fields || {}, preferences, previousFormState, renderAllFields: requiresRender, @@ -470,7 +474,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom parentPassesCondition: passesCondition, parentPath: path, parentSchemaPath: schemaPath, - permissions: fieldPermissions ?? permissions?.[field.name]?.fields ?? {}, + permissions: + typeof fieldPermissions === 'boolean' ? fieldPermissions : fieldPermissions?.fields, preferences, previousFormState, renderAllFields, @@ -614,7 +619,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom parentPassesCondition: passesCondition, parentPath, parentSchemaPath, - permissions, + permissions: parentPermissions, // TODO: Verify this is correct preferences, previousFormState, renderAllFields, @@ -643,6 +648,22 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom parentSchemaPath, }) + let childPermissions: SanitizedFieldsPermissions = undefined + if (tabHasName(tab)) { + if (parentPermissions === true) { + childPermissions = true + } else { + const tabPermissions = parentPermissions?.[tab.name] + if (tabPermissions === true) { + childPermissions = true + } else { + childPermissions = tabPermissions?.fields + } + } + } else { + childPermissions = parentPermissions + } + return iterateFields({ id, addErrorPathToParent: addErrorPathToParentArg, @@ -661,11 +682,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom parentPassesCondition: passesCondition, parentPath: tabHasName(tab) ? tabPath : parentPath, parentSchemaPath: tabHasName(tab) ? tabSchemaPath : parentSchemaPath, - permissions: tabHasName(tab) - ? typeof permissions === 'boolean' - ? permissions - : permissions?.[tab.name] || {} - : permissions, + permissions: childPermissions, preferences, previousFormState, renderAllFields, @@ -727,7 +744,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom parentPath, parentSchemaPath, path, - permissions, + permissions: fieldPermissions, preferences, previousFieldState: previousFormState?.[path], req, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx index 7c5d983af49..cc5bee53f87 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/index.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/index.tsx @@ -6,7 +6,7 @@ import type { FormState, FormStateWithoutComponents, PayloadRequest, - SanitizedDocumentPermissions, + SanitizedFieldsPermissions, } from 'payload' import type { RenderFieldMethod } from './types.js' @@ -26,7 +26,7 @@ type Args = { fieldSchemaMap: FieldSchemaMap | undefined id?: number | string operation?: 'create' | 'update' - permissions: SanitizedDocumentPermissions['fields'] + permissions: SanitizedFieldsPermissions preferences: DocumentPreferences /** * Optionally accept the previous form state, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts index 5a25d1f4f6f..e218f9aa3b5 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/iterateFields.ts @@ -6,7 +6,7 @@ import type { FormState, FormStateWithoutComponents, PayloadRequest, - SanitizedFieldPermissions, + SanitizedFieldsPermissions, } from 'payload' import type { AddFieldStatePromiseArgs } from './addFieldStatePromise.js' @@ -47,12 +47,7 @@ type Args = { parentPassesCondition?: boolean parentPath: string parentSchemaPath: string - permissions: - | { - [fieldName: string]: SanitizedFieldPermissions - } - | null - | SanitizedFieldPermissions + permissions: SanitizedFieldsPermissions preferences?: DocumentPreferences previousFormState: FormState renderAllFields: boolean @@ -130,9 +125,9 @@ export const iterateFields = async ({ operation, parentIndexPath, parentPath, + parentPermissions: permissions, parentSchemaPath, passesCondition, - permissions, preferences, previousFormState, renderAllFields, diff --git a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx index 9dbe5c17926..32d168460b0 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx +++ b/packages/ui/src/forms/fieldSchemasToFormState/renderField.tsx @@ -1,14 +1,7 @@ -import type { - ClientComponentProps, - ClientField, - FieldPaths, - SanitizedFieldPermissions, - ServerComponentProps, -} from 'payload' +import type { ClientComponentProps, ClientField, FieldPaths, ServerComponentProps } from 'payload' import { getTranslation } from '@payloadcms/translations' import { createClientField, deepCopyObjectSimple, MissingEditorProp } from 'payload' -import { fieldAffectsData } from 'payload/shared' import type { RenderFieldMethod } from './types.js' @@ -36,15 +29,12 @@ export const renderField: RenderFieldMethod = ({ parentPath, parentSchemaPath, path, - permissions: incomingPermissions, + permissions, preferences, req, schemaPath, siblingData, }) => { - // TODO (ALESSIO): why are we passing the fieldConfig twice? - // and especially, why are we deepCopyObject -here- instead of inside the createClientField func, - // so no one screws this up in the future? const clientField = createClientField({ clientField: deepCopyObjectSimple(fieldConfig) as ClientField, defaultIDType: req.payload.config.db.defaultIDType, @@ -53,18 +43,11 @@ export const renderField: RenderFieldMethod = ({ importMap: req.payload.importMap, }) - const permissions = - incomingPermissions === true - ? true - : fieldAffectsData(fieldConfig) - ? incomingPermissions?.[fieldConfig.name] - : ({} as SanitizedFieldPermissions) - const clientProps: ClientComponentProps & Partial = { customComponents: fieldState?.customComponents || {}, field: clientField, path, - readOnly: permissions !== true && !permissions?.[operation], + readOnly: typeof permissions === 'boolean' ? !permissions : !permissions?.[operation], schemaPath, } diff --git a/packages/ui/src/forms/fieldSchemasToFormState/types.ts b/packages/ui/src/forms/fieldSchemasToFormState/types.ts index 16fc3d74481..9e98fede870 100644 --- a/packages/ui/src/forms/fieldSchemasToFormState/types.ts +++ b/packages/ui/src/forms/fieldSchemasToFormState/types.ts @@ -23,12 +23,7 @@ export type RenderFieldArgs = { parentPath: string parentSchemaPath: string path: string - permissions: - | { - [fieldName: string]: SanitizedFieldPermissions - } - | null - | SanitizedFieldPermissions + permissions: SanitizedFieldPermissions preferences: DocumentPreferences previousFieldState: FieldState req: PayloadRequest diff --git a/test/access-control/collections/Regression-1/index.ts b/test/access-control/collections/Regression-1/index.ts new file mode 100644 index 00000000000..c75da6df5cd --- /dev/null +++ b/test/access-control/collections/Regression-1/index.ts @@ -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(), + }, + ], + }, + ], + }, + ], +} diff --git a/test/access-control/collections/Regression-2/index.ts b/test/access-control/collections/Regression-2/index.ts new file mode 100644 index 00000000000..58f8684618b --- /dev/null +++ b/test/access-control/collections/Regression-2/index.ts @@ -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(), + }, + ], + }, + ], +} diff --git a/test/access-control/config.ts b/test/access-control/config.ts index bbd84f42ca7..c7e7893c248 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -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'), diff --git a/test/access-control/e2e.spec.ts b/test/access-control/e2e.spec.ts index a9065372683..20ba77dd8d7 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -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', () => { diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index ff7ff52728c..e559854def1 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -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 | HiddenAccessCountSelect; disabled: DisabledSelect | DisabledSelect; 'rich-text': RichTextSelect | RichTextSelect; + regression1: Regression1Select | Regression1Select; + regression2: Regression2Select | Regression2Select; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -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 { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "regression1_select". + */ +export interface Regression1Select { + 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 { + 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". diff --git a/test/fields/collections/LexicalLocalized/textToLexicalJSON.ts b/test/fields/collections/LexicalLocalized/textToLexicalJSON.ts index 6912dc10a58..e7a2ae4676e 100644 --- a/test/fields/collections/LexicalLocalized/textToLexicalJSON.ts +++ b/test/fields/collections/LexicalLocalized/textToLexicalJSON.ts @@ -13,7 +13,7 @@ export function textToLexicalJSON({ }: { lexicalLocalizedRelID?: number | string text: string -}) { +}): any { const editorJSON: SerializedEditorState = { root: { type: 'root', @@ -39,6 +39,7 @@ export function textToLexicalJSON({ indent: 0, textFormat: 0, type: 'paragraph', + textStyle: '', version: 1, } as SerializedParagraphNode, ],