From 090a65ce4f358b980f65fde17e6e4ddcac2c58b9 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 8 Oct 2025 15:13:53 -0400 Subject: [PATCH 1/6] fix: hide fields with read: false in list view columns, filters, and groupBy --- packages/next/src/views/List/handleGroupBy.ts | 4 + packages/next/src/views/List/index.tsx | 2 + .../ui/src/elements/GroupByBuilder/index.tsx | 9 +- .../ui/src/elements/WhereBuilder/index.tsx | 12 +- .../hasFieldReadPermission.ts | 49 ++ .../TableColumns/buildColumnState/index.tsx | 14 + .../src/utilities/reduceFieldsToOptions.tsx | 19 +- packages/ui/src/utilities/renderTable.tsx | 5 + .../collections/ReadRestricted/index.ts | 224 +++++++ .../collections/ReadRestricted/seed.ts | 161 +++++ test/access-control/config.ts | 6 + test/access-control/e2e.spec.ts | 551 ++++++++++++++++++ test/access-control/payload-types.ts | 139 ++++- 13 files changed, 1180 insertions(+), 15 deletions(-) create mode 100644 packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts create mode 100644 test/access-control/collections/ReadRestricted/index.ts create mode 100644 test/access-control/collections/ReadRestricted/seed.ts diff --git a/packages/next/src/views/List/handleGroupBy.ts b/packages/next/src/views/List/handleGroupBy.ts index ff18aa2fa9a..be1fe168ed1 100644 --- a/packages/next/src/views/List/handleGroupBy.ts +++ b/packages/next/src/views/List/handleGroupBy.ts @@ -6,6 +6,7 @@ import type { PaginatedDocs, PayloadRequest, SanitizedCollectionConfig, + SanitizedFieldsPermissions, SelectType, ViewTypes, Where, @@ -28,6 +29,7 @@ export const handleGroupBy = async ({ customCellProps, drawerSlug, enableRowSelections, + fieldPermissions, query, req, select, @@ -44,6 +46,7 @@ export const handleGroupBy = async ({ customCellProps?: Record drawerSlug?: string enableRowSelections?: boolean + fieldPermissions?: SanitizedFieldsPermissions query?: ListQuery req: PayloadRequest select?: SelectType @@ -183,6 +186,7 @@ export const handleGroupBy = async ({ data: groupData, drawerSlug, enableRowSelections, + fieldPermissions, groupByFieldPath, groupByValue: serializableValue, heading: heading || req.i18n.t('general:noValue'), diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 65001ae0c16..59ca2ed37bf 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -259,6 +259,7 @@ export const renderListView = async ( customCellProps, drawerSlug, enableRowSelections, + fieldPermissions: permissions?.collections?.[collectionSlug]?.fields, query, req, select, @@ -293,6 +294,7 @@ export const renderListView = async ( data, drawerSlug, enableRowSelections, + fieldPermissions: permissions?.collections?.[collectionSlug]?.fields, i18n: req.i18n, orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined, payload: req.payload, diff --git a/packages/ui/src/elements/GroupByBuilder/index.tsx b/packages/ui/src/elements/GroupByBuilder/index.tsx index 20af9bcf8de..410bf41d09a 100644 --- a/packages/ui/src/elements/GroupByBuilder/index.tsx +++ b/packages/ui/src/elements/GroupByBuilder/index.tsx @@ -6,6 +6,7 @@ import './index.scss' import React, { useMemo } from 'react' import { SelectInput } from '../../fields/Select/Input.js' +import { useAuth } from '../../providers/Auth/index.js' import { useListQuery } from '../../providers/ListQuery/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { reduceFieldsToOptions } from '../../utilities/reduceFieldsToOptions.js' @@ -41,8 +42,14 @@ const supportedFieldTypes: Field['type'][] = [ export const GroupByBuilder: React.FC = ({ collectionSlug, fields }) => { const { i18n, t } = useTranslation() + const { permissions } = useAuth() - const reducedFields = useMemo(() => reduceFieldsToOptions({ fields, i18n }), [fields, i18n]) + const fieldPermissions = permissions?.collections?.[collectionSlug]?.fields + + const reducedFields = useMemo( + () => reduceFieldsToOptions({ fieldPermissions, fields, i18n }), + [fields, fieldPermissions, i18n], + ) const { query, refineListData } = useListQuery() diff --git a/packages/ui/src/elements/WhereBuilder/index.tsx b/packages/ui/src/elements/WhereBuilder/index.tsx index a1db810c1e7..bfebfb6e23b 100644 --- a/packages/ui/src/elements/WhereBuilder/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/index.tsx @@ -7,6 +7,7 @@ import React, { useMemo } from 'react' import type { AddCondition, RemoveCondition, UpdateCondition, WhereBuilderProps } from './types.js' +import { useAuth } from '../../providers/Auth/index.js' import { useListQuery } from '../../providers/ListQuery/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { reduceFieldsToOptions } from '../../utilities/reduceFieldsToOptions.js' @@ -24,10 +25,17 @@ export { WhereBuilderProps } * It is part of the {@link ListControls} component which is used to render the controls (search, filter, where). */ export const WhereBuilder: React.FC = (props) => { - const { collectionPluralLabel, fields, renderedFilters, resolvedFilterOptions } = props + const { collectionPluralLabel, collectionSlug, fields, renderedFilters, resolvedFilterOptions } = + props const { i18n, t } = useTranslation() + const { permissions } = useAuth() - const reducedFields = useMemo(() => reduceFieldsToOptions({ fields, i18n }), [fields, i18n]) + const fieldPermissions = permissions?.collections?.[collectionSlug]?.fields + + const reducedFields = useMemo( + () => reduceFieldsToOptions({ fieldPermissions, fields, i18n }), + [fieldPermissions, fields, i18n], + ) const { handleWhereChange, query } = useListQuery() diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts b/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts new file mode 100644 index 00000000000..bb8adeaab18 --- /dev/null +++ b/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts @@ -0,0 +1,49 @@ +import type { SanitizedFieldsPermissions } from 'payload' + +/** + * Check if a field has read permission by traversing the permissions structure using a dot-notation path. + * Handles both top-level fields (e.g., "restrictedField") and nested fields (e.g., "group.restrictedGroupText"). + * + * The permissions object only contains fields the user can access (fields with read: false are deleted). + * - If permissions is true, all fields are readable + * - If field not in permissions, it was filtered out (not readable) + * - If field in permissions with read: true, it's readable + * - Otherwise, not readable + */ +export const hasFieldReadPermission = ( + permissions: SanitizedFieldsPermissions, + path: string, +): boolean => { + if (permissions === true) {return true} + if (typeof permissions !== 'object') {return false} + + const pathParts = path.split('.') + + const checkPermission = (currentPerms: any, parts: string[], index: number = 0): boolean => { + const part = parts[index] + const isLastPart = index === parts.length - 1 + + // Field doesn't exist in permissions - was filtered out + if (!(part in currentPerms)) {return false} + + const fieldPerm = currentPerms[part] + + // Permission is explicitly true + if (fieldPerm === true) {return true} + + // At the last part - check for explicit read: true + if (isLastPart) { + return typeof fieldPerm === 'object' && fieldPerm.read === true + } + + // Need to navigate deeper - check if nested fields exist + if (typeof fieldPerm === 'object' && fieldPerm.fields) { + return checkPermission(fieldPerm.fields, parts, index + 1) + } + + // Can't navigate deeper - nested fields were filtered out + return false + } + + return checkPermission(permissions, pathParts) +} diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx b/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx index 51ea0b4b386..8a7a91554dc 100644 --- a/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx +++ b/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx @@ -12,6 +12,7 @@ import type { Payload, PayloadRequest, SanitizedCollectionConfig, + SanitizedFieldsPermissions, ServerComponentProps, StaticLabel, ViewTypes, @@ -33,6 +34,7 @@ import { // eslint-disable-next-line payload/no-imports-from-exports-dir -- MUST reference the exports dir: https://github.com/payloadcms/payload/issues/12002#issuecomment-2791493587 } from '../../../exports/client/index.js' import { filterFields } from './filterFields.js' +import { hasFieldReadPermission } from './hasFieldReadPermission.js' import { isColumnActive } from './isColumnActive.js' import { renderCell } from './renderCell.js' import { sortFieldMap } from './sortFieldMap.js' @@ -45,6 +47,7 @@ export type BuildColumnStateArgs = { enableLinkedCell?: boolean enableRowSelections: boolean enableRowTypes?: boolean + fieldPermissions?: SanitizedFieldsPermissions i18n: I18nClient payload: Payload req?: PayloadRequest @@ -79,6 +82,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { docs, enableLinkedCell = true, enableRowSelections, + fieldPermissions, i18n, payload, req, @@ -138,6 +142,16 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { const accessor = (clientField as any).accessor ?? ('name' in clientField ? clientField.name : undefined) + // Check read permissions for the field + // Skip permission check for ID field as it should always be visible + if (fieldPermissions && accessor && !fieldIsID(clientField)) { + const hasReadPermission = hasFieldReadPermission(fieldPermissions, accessor) + + if (!hasReadPermission) { + return acc + } + } + const serverField = _sortedFieldMap.find((f) => { const fAccessor = (f as any).accessor ?? ('name' in f ? f.name : undefined) return fAccessor === accessor diff --git a/packages/ui/src/utilities/reduceFieldsToOptions.tsx b/packages/ui/src/utilities/reduceFieldsToOptions.tsx index a26cfec8ade..b4f1ef1703b 100644 --- a/packages/ui/src/utilities/reduceFieldsToOptions.tsx +++ b/packages/ui/src/utilities/reduceFieldsToOptions.tsx @@ -1,6 +1,6 @@ 'use client' import type { ClientTranslationKeys, I18nClient } from '@payloadcms/translations' -import type { ClientField } from 'payload' +import type { ClientField, SanitizedFieldsPermissions } from 'payload' import { getTranslation } from '@payloadcms/translations' import { fieldAffectsData, fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared' @@ -9,9 +9,11 @@ import type { ReducedField } from '../elements/WhereBuilder/types.js' import fieldTypes, { arrayOperators } from '../elements/WhereBuilder/field-types.js' import { createNestedClientFieldPath } from '../forms/Form/createNestedClientFieldPath.js' +import { hasFieldReadPermission } from '../providers/TableColumns/buildColumnState/hasFieldReadPermission.js' import { combineFieldLabel } from './combineFieldLabel.js' type ReduceFieldOptionsArgs = { + fieldPermissions?: SanitizedFieldsPermissions fields: ClientField[] i18n: I18nClient labelPrefix?: string @@ -23,6 +25,7 @@ type ReduceFieldOptionsArgs = { * Used in the `WhereBuilder` component to render the fields in the dropdown. */ export const reduceFieldsToOptions = ({ + fieldPermissions, fields, i18n, labelPrefix, @@ -70,6 +73,7 @@ export const reduceFieldsToOptions = ({ if (typeof localizedTabLabel === 'string') { reduced.push( ...reduceFieldsToOptions({ + fieldPermissions, fields: tab.fields, i18n, labelPrefix: labelWithPrefix, @@ -86,6 +90,7 @@ export const reduceFieldsToOptions = ({ if (field.type === 'row' && 'fields' in field) { reduced.push( ...reduceFieldsToOptions({ + fieldPermissions, fields: field.fields, i18n, labelPrefix, @@ -104,6 +109,7 @@ export const reduceFieldsToOptions = ({ reduced.push( ...reduceFieldsToOptions({ + fieldPermissions, fields: field.fields, i18n, labelPrefix: labelWithPrefix, @@ -132,6 +138,7 @@ export const reduceFieldsToOptions = ({ reduced.push( ...reduceFieldsToOptions({ + fieldPermissions, fields: field.fields, i18n, labelPrefix: labelWithPrefix, @@ -141,6 +148,7 @@ export const reduceFieldsToOptions = ({ } else { reduced.push( ...reduceFieldsToOptions({ + fieldPermissions, fields: field.fields, i18n, labelPrefix: labelWithPrefix, @@ -170,6 +178,7 @@ export const reduceFieldsToOptions = ({ reduced.push( ...reduceFieldsToOptions({ + fieldPermissions, fields: field.fields, i18n, labelPrefix: labelWithPrefix, @@ -210,6 +219,14 @@ export const reduceFieldsToOptions = ({ const fieldPath = pathPrefix ? createNestedClientFieldPath(pathPrefix, field) : field.name + // Check read permissions - skip ID field as it should always be visible + if (fieldPermissions && fieldPath && !fieldIsID(field)) { + const hasReadPermission = hasFieldReadPermission(fieldPermissions, fieldPath) + if (!hasReadPermission) { + return reduced + } + } + const formattedField: ReducedField = { label: formattedLabel, plainTextLabel: `${labelPrefix ? labelPrefix + ' > ' : ''}${localizedLabel}`, diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index 0c96d83e247..24107174902 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -12,6 +12,7 @@ import type { Payload, PayloadRequest, SanitizedCollectionConfig, + SanitizedFieldsPermissions, ViewTypes, } from 'payload' @@ -72,6 +73,7 @@ export const renderTable = ({ customCellProps, data, enableRowSelections, + fieldPermissions, groupByFieldPath, groupByValue, heading, @@ -95,6 +97,7 @@ export const renderTable = ({ data?: PaginatedDocs | undefined drawerSlug?: string enableRowSelections: boolean + fieldPermissions?: SanitizedFieldsPermissions groupByFieldPath?: string groupByValue?: string heading?: string @@ -160,6 +163,7 @@ export const renderTable = ({ | 'columns' | 'customCellProps' | 'enableRowSelections' + | 'fieldPermissions' | 'i18n' | 'payload' | 'req' @@ -170,6 +174,7 @@ export const renderTable = ({ clientFields, columns, enableRowSelections, + fieldPermissions, i18n, // sortColumnProps, customCellProps, diff --git a/test/access-control/collections/ReadRestricted/index.ts b/test/access-control/collections/ReadRestricted/index.ts new file mode 100644 index 00000000000..0712c362062 --- /dev/null +++ b/test/access-control/collections/ReadRestricted/index.ts @@ -0,0 +1,224 @@ +import type { CollectionConfig } from 'payload' + +export const readRestrictedSlug = 'read-restricted' + +export const ReadRestricted: CollectionConfig = { + slug: readRestrictedSlug, + access: { + create: () => true, + delete: () => true, + read: () => true, + update: () => true, + }, + admin: { + groupBy: true, + }, + fields: [ + // Top-level restricted field + { + name: 'restrictedTopLevel', + type: 'text', + access: { + read: () => false, + }, + }, + // Top-level visible field + { + name: 'visibleTopLevel', + type: 'text', + }, + // Group with restricted nested field + { + name: 'contactInfo', + type: 'group', + fields: [ + { + name: 'email', + type: 'email', + }, + { + name: 'secretPhone', + type: 'text', + access: { + read: () => false, + }, + }, + { + name: 'publicPhone', + type: 'text', + }, + ], + }, + // Row with restricted field + { + type: 'row', + fields: [ + { + name: 'visibleInRow', + type: 'text', + }, + { + name: 'restrictedInRow', + type: 'text', + access: { + read: () => false, + }, + }, + ], + }, + // Collapsible with restricted field + { + type: 'collapsible', + label: 'Additional Info', + fields: [ + { + name: 'visibleInCollapsible', + type: 'text', + }, + { + name: 'restrictedInCollapsible', + type: 'text', + access: { + read: () => false, + }, + }, + ], + }, + // Array with restricted fields + { + name: 'items', + type: 'array', + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'secretDescription', + type: 'textarea', + access: { + read: () => false, + }, + }, + { + name: 'publicDescription', + type: 'textarea', + }, + ], + }, + // Tabs with restricted fields + { + type: 'tabs', + tabs: [ + { + label: 'Public Tab', + fields: [ + { + name: 'publicData', + type: 'text', + }, + { + name: 'secretInPublicTab', + type: 'text', + access: { + read: () => false, + }, + }, + ], + }, + { + label: 'Settings', + name: 'settings', + fields: [ + { + name: 'visibleSetting', + type: 'checkbox', + }, + { + name: 'restrictedSetting', + type: 'checkbox', + access: { + read: () => false, + }, + }, + ], + }, + ], + }, + // Deeply nested: Group > Group with restricted field + { + name: 'metadata', + type: 'group', + fields: [ + { + name: 'analytics', + type: 'group', + fields: [ + { + name: 'visibleMetric', + type: 'number', + }, + { + name: 'restrictedMetric', + type: 'number', + access: { + read: () => false, + }, + }, + ], + }, + ], + }, + // Group with row inside with restricted field + { + name: 'address', + type: 'group', + fields: [ + { + name: 'street', + type: 'text', + }, + { + type: 'row', + fields: [ + { + name: 'city', + type: 'text', + }, + { + name: 'secretPostalCode', + type: 'text', + access: { + read: () => false, + }, + }, + ], + }, + ], + }, + // Collapsible with group inside with restricted field + { + type: 'collapsible', + label: 'Advanced Settings', + fields: [ + { + name: 'advanced', + type: 'group', + fields: [ + { + name: 'visibleAdvanced', + type: 'text', + }, + { + name: 'restrictedAdvanced', + type: 'text', + access: { + read: () => false, + }, + }, + ], + }, + ], + }, + ], +} diff --git a/test/access-control/collections/ReadRestricted/seed.ts b/test/access-control/collections/ReadRestricted/seed.ts new file mode 100644 index 00000000000..78ff30dd043 --- /dev/null +++ b/test/access-control/collections/ReadRestricted/seed.ts @@ -0,0 +1,161 @@ +import type { Payload } from 'payload' + +import { readRestrictedSlug } from './index.js' + +export const seedReadRestricted = async (payload: Payload): Promise => { + await payload.create({ + collection: readRestrictedSlug, + data: { + // Top-level fields + restrictedTopLevel: 'This should be hidden', + visibleTopLevel: 'This is visible to everyone', + + // Group fields + contactInfo: { + email: 'contact@example.com', + secretPhone: '+1-555-SECRET', + publicPhone: '+1-555-PUBLIC', + }, + + // Row fields + visibleInRow: 'Visible row data', + restrictedInRow: 'Hidden row data', + + // Collapsible fields + visibleInCollapsible: 'Visible collapsible data', + restrictedInCollapsible: 'Hidden collapsible data', + + // Array fields + items: [ + { + title: 'Item 1', + secretDescription: 'Secret details about item 1', + publicDescription: 'Public details about item 1', + }, + { + title: 'Item 2', + secretDescription: 'Secret details about item 2', + publicDescription: 'Public details about item 2', + }, + { + title: 'Item 3', + secretDescription: 'Secret details about item 3', + publicDescription: 'Public details about item 3', + }, + ], + + // Tab fields + publicData: 'Public tab information', + secretInPublicTab: 'Secret in public tab', + settings: { + visibleSetting: true, + restrictedSetting: true, + }, + + // Deeply nested group fields + metadata: { + analytics: { + visibleMetric: 1000, + restrictedMetric: 9999, + }, + }, + + // Group with row inside + address: { + street: '123 Main Street', + city: 'Springfield', + secretPostalCode: '12345-SECRET', + }, + + // Collapsible with group inside + advanced: { + visibleAdvanced: 'Visible advanced setting', + restrictedAdvanced: 'Hidden advanced setting', + }, + }, + }) + + await payload.create({ + collection: readRestrictedSlug, + data: { + restrictedTopLevel: 'Another hidden top level', + visibleTopLevel: 'Another visible field', + contactInfo: { + email: 'info@example.com', + secretPhone: '+1-555-HIDDEN', + publicPhone: '+1-555-VISIBLE', + }, + visibleInRow: 'Row visible text', + restrictedInRow: 'Row hidden text', + visibleInCollapsible: 'Collapsible visible', + restrictedInCollapsible: 'Collapsible hidden', + items: [ + { + title: 'Product A', + secretDescription: 'Confidential product info', + publicDescription: 'Public product description', + }, + ], + publicData: 'More public data', + secretInPublicTab: 'More secret data', + settings: { + visibleSetting: false, + restrictedSetting: false, + }, + metadata: { + analytics: { + visibleMetric: 2500, + restrictedMetric: 8888, + }, + }, + address: { + street: '456 Oak Avenue', + city: 'Portland', + secretPostalCode: '67890-SECRET', + }, + advanced: { + visibleAdvanced: 'Public advanced config', + restrictedAdvanced: 'Private advanced config', + }, + }, + }) + + await payload.create({ + collection: readRestrictedSlug, + data: { + restrictedTopLevel: 'Third hidden value', + visibleTopLevel: 'Third visible value', + contactInfo: { + email: 'support@example.com', + secretPhone: '+1-555-PRIVATE', + publicPhone: '+1-555-SUPPORT', + }, + visibleInRow: 'Third row visible', + restrictedInRow: 'Third row hidden', + visibleInCollapsible: 'Third collapsible visible', + restrictedInCollapsible: 'Third collapsible hidden', + items: [], + publicData: 'Third public data', + secretInPublicTab: 'Third secret data', + settings: { + visibleSetting: true, + restrictedSetting: false, + }, + metadata: { + analytics: { + visibleMetric: 750, + restrictedMetric: 5555, + }, + }, + address: { + street: '789 Pine Road', + city: 'Seattle', + secretPostalCode: '54321-SECRET', + }, + advanced: { + visibleAdvanced: 'Third advanced visible', + restrictedAdvanced: 'Third advanced hidden', + }, + }, + }) +} diff --git a/test/access-control/config.ts b/test/access-control/config.ts index d5901594fd9..c2187e35c62 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -13,6 +13,8 @@ import { devUser } from '../credentials.js' import { Auth } from './collections/Auth/index.js' import { Disabled } from './collections/Disabled/index.js' import { Hooks } from './collections/hooks/index.js' +import { ReadRestricted } from './collections/ReadRestricted/index.js' +import { seedReadRestricted } from './collections/ReadRestricted/seed.js' import { Regression1 } from './collections/Regression-1/index.js' import { Regression2 } from './collections/Regression-2/index.js' import { RichText } from './collections/RichText/index.js' @@ -578,6 +580,7 @@ export default buildConfigWithDefaults( Regression2, Hooks, Auth, + ReadRestricted, ], globals: [ { @@ -767,6 +770,9 @@ export default buildConfigWithDefaults( }, }, }) + + // Seed read-restricted collection + await seedReadRestricted(payload) }, 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 36f6ad9965d..2bbd64cd756 100644 --- a/test/access-control/e2e.spec.ts +++ b/test/access-control/e2e.spec.ts @@ -18,10 +18,13 @@ import { } from '../helpers.js' import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' import { login } from '../helpers/e2e/auth/login.js' +import { openListFilters } from '../helpers/e2e/filters/index.js' +import { openGroupBy } from '../helpers/e2e/groupBy/index.js' import { openDocControls } from '../helpers/e2e/openDocControls.js' import { closeNav, openNav } from '../helpers/e2e/toggleNav.js' import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js' +import { readRestrictedSlug } from './collections/ReadRestricted/index.js' import { authSlug, createNotUpdateCollectionSlug, @@ -761,6 +764,554 @@ describe('Access Control', () => { await expect(changePasswordButton).toBeHidden() }) }) + + describe('field read access restrictions in list view', () => { + let readRestrictedUrl: AdminUrlUtil + + beforeAll(() => { + readRestrictedUrl = new AdminUrlUtil(serverURL, readRestrictedSlug) + }) + + describe('column selector', () => { + test('should hide top-level field with read: false in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide restrictedTopLevel field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Restricted Top Level'), + }), + ).toBeHidden() + + // Should show visibleTopLevel field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Visible Top Level'), + }), + ).toBeVisible() + }) + + test('should hide nested field with read: false inside group in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide secretPhone field inside contactInfo group + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Contact Info > Secret Phone'), + }), + ).toBeHidden() + + // Should show publicPhone field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Contact Info > Public Phone'), + }), + ).toBeVisible() + }) + + test('should hide field with read: false inside row in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide restrictedInRow field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Restricted In Row'), + }), + ).toBeHidden() + + // Should show visibleInRow field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Visible In Row'), + }), + ).toBeVisible() + }) + + test('should hide field with read: false inside collapsible in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide restrictedInCollapsible field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Restricted In Collapsible'), + }), + ).toBeHidden() + + // Should show visibleInCollapsible field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Visible In Collapsible'), + }), + ).toBeVisible() + }) + + test('should hide deeply nested field with read: false in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide metadata.analytics.restrictedMetric field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Metadata > Analytics > Restricted Metric'), + }), + ).toBeHidden() + + // Should show metadata.analytics.visibleMetric field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Metadata > Analytics > Visible Metric'), + }), + ).toBeVisible() + }) + + test('should hide field with read: false inside unnamed tab in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide secretInPublicTab field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Secret In Public Tab'), + }), + ).toBeHidden() + + // Should show publicData field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Public Data'), + }), + ).toBeVisible() + }) + + test('should hide field with read: false inside named tab in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide restrictedSetting field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Settings > Restricted Setting'), + }), + ).toBeHidden() + + // Should show visibleSetting field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Settings > Visible Setting'), + }), + ).toBeVisible() + }) + + test('should hide field with read: false inside row within group in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide secretPostalCode field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Address > Secret Postal Code'), + }), + ).toBeHidden() + + // Should show city field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Address > City'), + }), + ).toBeVisible() + }) + + test('should hide field with read: false inside group within collapsible in column selector', async () => { + await page.goto(readRestrictedUrl.list) + await page.locator('.list-controls__toggle-columns').click() + + await expect(page.locator('.pill-selector')).toBeVisible() + + // Should hide restrictedAdvanced field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Advanced > Restricted Advanced'), + }), + ).toBeHidden() + + // Should show visibleAdvanced field + await expect( + page.locator(`.pill-selector .pill-selector__pill`, { + hasText: exactText('Advanced > Visible Advanced'), + }), + ).toBeVisible() + }) + }) + + describe('filter dropdown', () => { + test('should hide top-level field with read: false in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Visible Top Level', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedTopLevel field + await expect( + initialField.locator('.rs__option', { hasText: 'Restricted Top Level' }), + ).toBeHidden() + }) + + test('should hide nested field with read: false inside group in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Public Phone', + }) + await expect(visibleOption).toBeVisible() + + // Should hide secretPhone field + await expect(initialField.locator('.rs__option', { hasText: 'Secret Phone' })).toBeHidden() + }) + + test('should hide field with read: false inside row in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Visible In Row', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedInRow field + await expect( + initialField.locator('.rs__option', { hasText: 'Restricted In Row' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside collapsible in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Visible In Collapsible', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedInCollapsible field + await expect( + initialField.locator('.rs__option', { hasText: 'Restricted In Collapsible' }), + ).toBeHidden() + }) + + test('should hide deeply nested field with read: false in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Visible Metric', + }) + await expect(visibleOption).toBeVisible() + + // Should hide metadata.analytics.restrictedMetric field + await expect( + initialField.locator('.rs__option', { hasText: 'Restricted Metric' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside unnamed tab in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Public Tab > Public Data', + }) + await expect(visibleOption).toBeVisible() + + // Should hide secretInPublicTab field + await expect( + initialField.locator('.rs__option', { hasText: 'Public Tab > Secret In Public Tab' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside named tab in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Settings > Visible Setting', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedSetting field + await expect( + initialField.locator('.rs__option', { hasText: 'Settings > Restricted Setting' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside row within group in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Address > City', + }) + await expect(visibleOption).toBeVisible() + + // Should hide secretPostalCode field + await expect( + initialField.locator('.rs__option', { hasText: 'Address > Secret Postal Code' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside group within collapsible in filter dropdown', async () => { + await page.goto(readRestrictedUrl.list) + await openListFilters(page, {}) + await page.locator('.where-builder__add-first-filter').click() + + const initialField = page.locator('.condition__field') + await initialField.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = initialField.locator('.rs__option', { + hasText: 'Advanced Settings > Advanced > Visible Advanced', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedAdvanced field + await expect( + initialField.locator('.rs__option', { + hasText: 'Advanced Settings > Advanced > Restricted Advanced', + }), + ).toBeHidden() + }) + }) + + describe('groupBy dropdown', () => { + test('should hide top-level field with read: false in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Visible Top Level', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedTopLevel field + await expect(field.locator('.rs__option', { hasText: 'Restricted Top Level' })).toBeHidden() + }) + + test('should hide nested field with read: false inside group in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Public Phone', + }) + await expect(visibleOption).toBeVisible() + + // Should hide secretPhone field + await expect(field.locator('.rs__option', { hasText: 'Secret Phone' })).toBeHidden() + }) + + test('should hide field with read: false inside row in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Visible In Row', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedInRow field + await expect(field.locator('.rs__option', { hasText: 'Restricted In Row' })).toBeHidden() + }) + + test('should hide field with read: false inside collapsible in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Visible In Collapsible', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedInCollapsible field + await expect( + field.locator('.rs__option', { hasText: 'Restricted In Collapsible' }), + ).toBeHidden() + }) + + test('should hide deeply nested field with read: false in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Visible Metric', + }) + await expect(visibleOption).toBeVisible() + + // Should hide metadata.analytics.restrictedMetric field + await expect(field.locator('.rs__option', { hasText: 'Restricted Metric' })).toBeHidden() + }) + + test('should hide field with read: false inside unnamed tab in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Public Tab > Public Data', + }) + await expect(visibleOption).toBeVisible() + + // Should hide secretInPublicTab field + await expect( + field.locator('.rs__option', { hasText: 'Public Tab > Secret In Public Tab' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside named tab in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Settings > Visible Setting', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedSetting field + await expect( + field.locator('.rs__option', { hasText: 'Settings > Restricted Setting' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside row within group in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Address > City', + }) + await expect(visibleOption).toBeVisible() + + // Should hide secretPostalCode field + await expect( + field.locator('.rs__option', { hasText: 'Address > Secret Postal Code' }), + ).toBeHidden() + }) + + test('should hide field with read: false inside group within collapsible in groupBy dropdown', async () => { + await page.goto(readRestrictedUrl.list) + const { groupByContainer } = await openGroupBy(page) + + const field = groupByContainer.locator('#group-by--field-select') + await field.click() + + // Wait for dropdown options to load by waiting for the visible field + const visibleOption = field.locator('.rs__option', { + hasText: 'Advanced Settings > Advanced > Visible Advanced', + }) + await expect(visibleOption).toBeVisible() + + // Should hide restrictedAdvanced field + await expect( + field.locator('.rs__option', { + hasText: 'Advanced Settings > Advanced > Restricted Advanced', + }), + ).toBeHidden() + }) + }) + }) }) async function createDoc(data: any): Promise & TypeWithID> { diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 7653ce79681..163dba295ed 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -93,6 +93,7 @@ export interface Config { regression2: Regression2; hooks: Hook; 'auth-collection': AuthCollection; + 'read-restricted': ReadRestricted; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; 'payload-migrations': PayloadMigration; @@ -123,6 +124,7 @@ export interface Config { regression2: Regression2Select | Regression2Select; hooks: HooksSelect | HooksSelect; 'auth-collection': AuthCollectionSelect | AuthCollectionSelect; + 'read-restricted': ReadRestrictedSelect | ReadRestrictedSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; @@ -420,6 +422,7 @@ export interface HiddenField { }[] | null; hidden?: boolean | null; + hiddenWithDefault?: string | null; updatedAt: string; createdAt: string; } @@ -490,7 +493,7 @@ export interface RichText { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -520,7 +523,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -538,7 +541,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -555,7 +558,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -576,7 +579,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -593,7 +596,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -615,7 +618,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -635,7 +638,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -655,7 +658,7 @@ export interface Regression1 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -685,7 +688,7 @@ export interface Regression2 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -704,7 +707,7 @@ export interface Regression2 { root: { type: string; children: { - type: string; + type: any; version: number; [k: string]: unknown; }[]; @@ -760,6 +763,55 @@ export interface AuthCollection { }[] | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "read-restricted". + */ +export interface ReadRestricted { + id: string; + restrictedTopLevel?: string | null; + visibleTopLevel?: string | null; + contactInfo?: { + email?: string | null; + secretPhone?: string | null; + publicPhone?: string | null; + }; + visibleInRow?: string | null; + restrictedInRow?: string | null; + visibleInCollapsible?: string | null; + restrictedInCollapsible?: string | null; + items?: + | { + title?: string | null; + secretDescription?: string | null; + publicDescription?: string | null; + id?: string | null; + }[] + | null; + publicData?: string | null; + secretInPublicTab?: string | null; + settings?: { + visibleSetting?: boolean | null; + restrictedSetting?: boolean | null; + }; + metadata?: { + analytics?: { + visibleMetric?: number | null; + restrictedMetric?: number | null; + }; + }; + address?: { + street?: string | null; + city?: string | null; + secretPostalCode?: string | null; + }; + advanced?: { + visibleAdvanced?: string | null; + restrictedAdvanced?: string | null; + }; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents". @@ -862,6 +914,10 @@ export interface PayloadLockedDocument { | ({ relationTo: 'auth-collection'; value: string | AuthCollection; + } | null) + | ({ + relationTo: 'read-restricted'; + value: string | ReadRestricted; } | null); globalSlug?: string | null; user: @@ -1116,6 +1172,7 @@ export interface HiddenFieldsSelect { id?: T; }; hidden?: T; + hiddenWithDefault?: T; updatedAt?: T; createdAt?: T; } @@ -1315,6 +1372,66 @@ export interface AuthCollectionSelect { expiresAt?: T; }; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "read-restricted_select". + */ +export interface ReadRestrictedSelect { + restrictedTopLevel?: T; + visibleTopLevel?: T; + contactInfo?: + | T + | { + email?: T; + secretPhone?: T; + publicPhone?: T; + }; + visibleInRow?: T; + restrictedInRow?: T; + visibleInCollapsible?: T; + restrictedInCollapsible?: T; + items?: + | T + | { + title?: T; + secretDescription?: T; + publicDescription?: T; + id?: T; + }; + publicData?: T; + secretInPublicTab?: T; + settings?: + | T + | { + visibleSetting?: T; + restrictedSetting?: T; + }; + metadata?: + | T + | { + analytics?: + | T + | { + visibleMetric?: T; + restrictedMetric?: T; + }; + }; + address?: + | T + | { + street?: T; + city?: T; + secretPostalCode?: T; + }; + advanced?: + | T + | { + visibleAdvanced?: T; + restrictedAdvanced?: T; + }; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-locked-documents_select". From 5a5f4eb63738282827257df437321e0ce173e204 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Wed, 8 Oct 2025 16:09:15 -0400 Subject: [PATCH 2/6] fix: remove irrelevant test --- test/admin/e2e/list-view/e2e.spec.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 0cca5820aa7..5f4f2574836 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -836,27 +836,6 @@ describe('List View', () => { ).toBeHidden() }) - test('should show no results when queryin on a field a user cannot read', async () => { - await payload.create({ - collection: postsCollectionSlug, - data: { - noReadAccessField: 'test', - }, - }) - - await page.goto(postsUrl.list) - - const { whereBuilder } = await addListFilter({ - page, - fieldLabel: 'No Read Access Field', - operatorLabel: 'equals', - value: 'test', - }) - - await expect(whereBuilder.locator('.condition__value input')).toBeVisible() - await expect(page.locator('.collection-list__no-results')).toBeVisible() - }) - test('should properly paginate many documents', async () => { await page.goto(with300DocumentsUrl.list) From f42d573fc55e5457b7b8f0d45faa42ccfa78304d Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:37:36 -0400 Subject: [PATCH 3/6] fix: pass fieldPermissions to renderTable in buildTableState --- packages/ui/src/utilities/buildTableState.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/utilities/buildTableState.ts b/packages/ui/src/utilities/buildTableState.ts index 2d54b5d44a8..be15ea5c1cd 100644 --- a/packages/ui/src/utilities/buildTableState.ts +++ b/packages/ui/src/utilities/buildTableState.ts @@ -11,7 +11,7 @@ import type { Where, } from 'payload' -import { APIError, canAccessAdmin, formatErrors } from 'payload' +import { APIError, canAccessAdmin, formatErrors, getAccessResults } from 'payload' import { isNumber } from 'payload/shared' import { getClientConfig } from './getClientConfig.js' @@ -100,6 +100,8 @@ const buildTableState = async ( user, }) + const permissions = await getAccessResults({ req }) + let collectionConfig: SanitizedCollectionConfig let clientCollectionConfig: ClientCollectionConfig @@ -208,6 +210,9 @@ const buildTableState = async ( }), data, enableRowSelections, + fieldPermissions: Array.isArray(collectionSlug) + ? undefined + : permissions?.collections?.[collectionSlug]?.fields, i18n: req.i18n, orderableFieldName, payload, From 7bb0fdd922e9dee99240a1beef3e88df0ca68171 Mon Sep 17 00:00:00 2001 From: Patrik Kozak <35232443+PatrikKozak@users.noreply.github.com> Date: Fri, 10 Oct 2025 16:44:05 -0400 Subject: [PATCH 4/6] fix: simplifies permissions logic --- packages/next/src/views/List/index.tsx | 1 + .../ui/src/elements/GroupByBuilder/index.tsx | 7 +- .../ui/src/elements/WhereBuilder/index.tsx | 7 +- .../filterFieldsWithPermissions.tsx | 77 ++++++++++++++ .../hasFieldReadPermission.ts | 16 ++- .../TableColumns/buildColumnState/index.tsx | 41 ++++--- packages/ui/src/utilities/buildTableState.ts | 3 + packages/ui/src/utilities/getColumns.ts | 18 +++- .../src/utilities/reduceFieldsToOptions.tsx | 100 ++++++++++-------- packages/ui/src/utilities/renderTable.tsx | 12 ++- 10 files changed, 200 insertions(+), 82 deletions(-) create mode 100644 packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 59ca2ed37bf..4cc9c624cd0 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -235,6 +235,7 @@ export const renderListView = async ( collectionConfig: clientCollectionConfig, collectionSlug, columns: collectionPreferences?.columns, + fieldPermissions: permissions?.collections?.[collectionSlug]?.fields, i18n, }) diff --git a/packages/ui/src/elements/GroupByBuilder/index.tsx b/packages/ui/src/elements/GroupByBuilder/index.tsx index 410bf41d09a..2ebe59803b0 100644 --- a/packages/ui/src/elements/GroupByBuilder/index.tsx +++ b/packages/ui/src/elements/GroupByBuilder/index.tsx @@ -47,7 +47,12 @@ export const GroupByBuilder: React.FC = ({ collectionSlug, fields }) => { const fieldPermissions = permissions?.collections?.[collectionSlug]?.fields const reducedFields = useMemo( - () => reduceFieldsToOptions({ fieldPermissions, fields, i18n }), + () => + reduceFieldsToOptions({ + fieldPermissions, + fields, + i18n, + }), [fields, fieldPermissions, i18n], ) diff --git a/packages/ui/src/elements/WhereBuilder/index.tsx b/packages/ui/src/elements/WhereBuilder/index.tsx index bfebfb6e23b..649145891e3 100644 --- a/packages/ui/src/elements/WhereBuilder/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/index.tsx @@ -33,7 +33,12 @@ export const WhereBuilder: React.FC = (props) => { const fieldPermissions = permissions?.collections?.[collectionSlug]?.fields const reducedFields = useMemo( - () => reduceFieldsToOptions({ fieldPermissions, fields, i18n }), + () => + reduceFieldsToOptions({ + fieldPermissions, + fields, + i18n, + }), [fieldPermissions, fields, i18n], ) diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx b/packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx new file mode 100644 index 00000000000..c7db95f4415 --- /dev/null +++ b/packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx @@ -0,0 +1,77 @@ +import type { + ClientField, + Field, + SanitizedFieldPermissions, + SanitizedFieldsPermissions, +} from 'payload' + +import { fieldAffectsData, fieldIsHiddenOrDisabled, fieldIsID } from 'payload/shared' + +export const filterFieldsWithPermissions = ({ + fieldPermissions, + fields, +}: { + fieldPermissions?: SanitizedFieldPermissions | SanitizedFieldsPermissions + fields: T[] +}): T[] => { + const shouldSkipField = (field: T): boolean => + (field.type !== 'ui' && fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) || + field?.admin?.disableListColumn === true + + return (fields ?? []).reduce((acc, field) => { + if (shouldSkipField(field)) { + return acc + } + + // handle tabs + if (field.type === 'tabs' && 'tabs' in field) { + const formattedField: T = { + ...field, + tabs: field.tabs.map((tab) => ({ + ...tab, + fields: filterFieldsWithPermissions({ + fieldPermissions: + typeof fieldPermissions === 'boolean' + ? fieldPermissions + : 'name' in tab && tab.name + ? fieldPermissions[tab.name]?.fields || fieldPermissions[tab.name] + : fieldPermissions, + fields: tab.fields, + }), + })), + } + acc.push(formattedField) + return acc + } + + // handle fields with subfields (row, group, collapsible, etc.) + if ('fields' in field && Array.isArray(field.fields)) { + const formattedField: T = { + ...field, + fields: filterFieldsWithPermissions({ + fieldPermissions: + typeof fieldPermissions === 'boolean' + ? fieldPermissions + : 'name' in field && field.name + ? fieldPermissions[field.name]?.fields || fieldPermissions[field.name] + : fieldPermissions, + fields: field.fields as T[], + }), + } + acc.push(formattedField) + return acc + } + + if (fieldPermissions === true) { + acc.push(field) + return acc + } + + if (fieldAffectsData(field) && fieldPermissions[field.name] === true) { + } + + // leaf + acc.push(field) + return acc + }, []) +} diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts b/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts index bb8adeaab18..2ea6d11ddf4 100644 --- a/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts +++ b/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts @@ -14,8 +14,12 @@ export const hasFieldReadPermission = ( permissions: SanitizedFieldsPermissions, path: string, ): boolean => { - if (permissions === true) {return true} - if (typeof permissions !== 'object') {return false} + if (permissions === true) { + return true + } + if (typeof permissions !== 'object') { + return false + } const pathParts = path.split('.') @@ -24,12 +28,16 @@ export const hasFieldReadPermission = ( const isLastPart = index === parts.length - 1 // Field doesn't exist in permissions - was filtered out - if (!(part in currentPerms)) {return false} + if (!(part in currentPerms)) { + return false + } const fieldPerm = currentPerms[part] // Permission is explicitly true - if (fieldPerm === true) {return true} + if (fieldPerm === true) { + return true + } // At the last part - check for explicit read: true if (isLastPart) { diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx b/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx index 8a7a91554dc..f5ac6f216d0 100644 --- a/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx +++ b/packages/ui/src/providers/TableColumns/buildColumnState/index.tsx @@ -33,8 +33,7 @@ import { SortColumn, // eslint-disable-next-line payload/no-imports-from-exports-dir -- MUST reference the exports dir: https://github.com/payloadcms/payload/issues/12002#issuecomment-2791493587 } from '../../../exports/client/index.js' -import { filterFields } from './filterFields.js' -import { hasFieldReadPermission } from './hasFieldReadPermission.js' +import { filterFieldsWithPermissions } from './filterFieldsWithPermissions.js' import { isColumnActive } from './isColumnActive.js' import { renderCell } from './renderCell.js' import { sortFieldMap } from './sortFieldMap.js' @@ -93,17 +92,23 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { } = args // clientFields contains the fake `id` column - let sortedFieldMap = flattenTopLevelFields(filterFields(clientFields), { - i18n, - keepPresentationalFields: true, - moveSubFieldsToTop: true, - }) as ClientField[] - - let _sortedFieldMap = flattenTopLevelFields(filterFields(serverFields), { - i18n, - keepPresentationalFields: true, - moveSubFieldsToTop: true, - }) as Field[] // TODO: think of a way to avoid this additional flatten + let sortedFieldMap = flattenTopLevelFields( + filterFieldsWithPermissions({ fieldPermissions, fields: clientFields }), + { + i18n, + keepPresentationalFields: true, + moveSubFieldsToTop: true, + }, + ) as ClientField[] + + let _sortedFieldMap = flattenTopLevelFields( + filterFieldsWithPermissions({ fieldPermissions, fields: serverFields }), + { + i18n, + keepPresentationalFields: true, + moveSubFieldsToTop: true, + }, + ) as Field[] // TODO: think of a way to avoid this additional flatten // place the `ID` field first, if it exists // do the same for the `useAsTitle` field with precedence over the `ID` field @@ -142,16 +147,6 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => { const accessor = (clientField as any).accessor ?? ('name' in clientField ? clientField.name : undefined) - // Check read permissions for the field - // Skip permission check for ID field as it should always be visible - if (fieldPermissions && accessor && !fieldIsID(clientField)) { - const hasReadPermission = hasFieldReadPermission(fieldPermissions, accessor) - - if (!hasReadPermission) { - return acc - } - } - const serverField = _sortedFieldMap.find((f) => { const fAccessor = (f as any).accessor ?? ('name' in f ? f.name : undefined) return fAccessor === accessor diff --git a/packages/ui/src/utilities/buildTableState.ts b/packages/ui/src/utilities/buildTableState.ts index be15ea5c1cd..e3c08a19358 100644 --- a/packages/ui/src/utilities/buildTableState.ts +++ b/packages/ui/src/utilities/buildTableState.ts @@ -206,6 +206,9 @@ const buildTableState = async ( collectionConfig: clientCollectionConfig, collectionSlug, columns: columnsFromArgs, + fieldPermissions: Array.isArray(collectionSlug) + ? undefined + : permissions?.collections?.[collectionSlug]?.fields, i18n: req.i18n, }), data, diff --git a/packages/ui/src/utilities/getColumns.ts b/packages/ui/src/utilities/getColumns.ts index 2bb39779f7a..3a4976b7ba8 100644 --- a/packages/ui/src/utilities/getColumns.ts +++ b/packages/ui/src/utilities/getColumns.ts @@ -1,10 +1,15 @@ import type { I18nClient } from '@payloadcms/translations' -import type { ClientCollectionConfig, ClientConfig, ColumnPreference } from 'payload' +import type { + ClientCollectionConfig, + ClientConfig, + ColumnPreference, + SanitizedFieldsPermissions, +} from 'payload' import { flattenTopLevelFields } from 'payload' import { fieldAffectsData } from 'payload/shared' -import { filterFields } from '../providers/TableColumns/buildColumnState/filterFields.js' +import { filterFieldsWithPermissions } from '../providers/TableColumns/buildColumnState/filterFieldsWithPermissions.js' import { getInitialColumns } from '../providers/TableColumns/getInitialColumns.js' export const getColumns = ({ @@ -12,12 +17,14 @@ export const getColumns = ({ collectionConfig, collectionSlug, columns, + fieldPermissions, i18n, }: { clientConfig: ClientConfig collectionConfig?: ClientCollectionConfig collectionSlug: string | string[] columns: ColumnPreference[] + fieldPermissions: SanitizedFieldsPermissions i18n: I18nClient }) => { const isPolymorphic = Array.isArray(collectionSlug) @@ -30,7 +37,10 @@ export const getColumns = ({ (each) => each.slug === collection, ) - for (const field of filterFields(clientCollectionConfig.fields)) { + for (const field of filterFieldsWithPermissions({ + fieldPermissions, + fields: clientCollectionConfig.fields, + })) { if (fieldAffectsData(field)) { if (fields.some((each) => fieldAffectsData(each) && each.name === field.name)) { continue @@ -55,7 +65,7 @@ export const getColumns = ({ }), ) : getInitialColumns( - isPolymorphic ? fields : filterFields(fields), + isPolymorphic ? fields : filterFieldsWithPermissions({ fieldPermissions, fields }), collectionConfig?.admin?.useAsTitle, isPolymorphic ? [] : collectionConfig?.admin?.defaultColumns, ) diff --git a/packages/ui/src/utilities/reduceFieldsToOptions.tsx b/packages/ui/src/utilities/reduceFieldsToOptions.tsx index b4f1ef1703b..a3e99cb151b 100644 --- a/packages/ui/src/utilities/reduceFieldsToOptions.tsx +++ b/packages/ui/src/utilities/reduceFieldsToOptions.tsx @@ -1,6 +1,6 @@ 'use client' import type { ClientTranslationKeys, I18nClient } from '@payloadcms/translations' -import type { ClientField, SanitizedFieldsPermissions } from 'payload' +import type { ClientField, SanitizedFieldPermissions, SanitizedFieldsPermissions } from 'payload' import { getTranslation } from '@payloadcms/translations' import { fieldAffectsData, fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared' @@ -9,11 +9,10 @@ import type { ReducedField } from '../elements/WhereBuilder/types.js' import fieldTypes, { arrayOperators } from '../elements/WhereBuilder/field-types.js' import { createNestedClientFieldPath } from '../forms/Form/createNestedClientFieldPath.js' -import { hasFieldReadPermission } from '../providers/TableColumns/buildColumnState/hasFieldReadPermission.js' import { combineFieldLabel } from './combineFieldLabel.js' type ReduceFieldOptionsArgs = { - fieldPermissions?: SanitizedFieldsPermissions + fieldPermissions?: SanitizedFieldPermissions | SanitizedFieldsPermissions fields: ClientField[] i18n: I18nClient labelPrefix?: string @@ -73,7 +72,12 @@ export const reduceFieldsToOptions = ({ if (typeof localizedTabLabel === 'string') { reduced.push( ...reduceFieldsToOptions({ - fieldPermissions, + fieldPermissions: + typeof fieldPermissions === 'boolean' + ? fieldPermissions + : tabHasName(tab) && tab.name + ? fieldPermissions[tab.name]?.fields || fieldPermissions[tab.name] + : fieldPermissions, fields: tab.fields, i18n, labelPrefix: labelWithPrefix, @@ -138,7 +142,10 @@ export const reduceFieldsToOptions = ({ reduced.push( ...reduceFieldsToOptions({ - fieldPermissions, + fieldPermissions: + typeof fieldPermissions === 'boolean' + ? fieldPermissions + : fieldPermissions[field.name]?.fields || fieldPermissions[field.name], fields: field.fields, i18n, labelPrefix: labelWithPrefix, @@ -178,7 +185,10 @@ export const reduceFieldsToOptions = ({ reduced.push( ...reduceFieldsToOptions({ - fieldPermissions, + fieldPermissions: + typeof fieldPermissions === 'boolean' + ? fieldPermissions + : fieldPermissions[field.name]?.fields || fieldPermissions[field.name], fields: field.fields, i18n, labelPrefix: labelWithPrefix, @@ -190,54 +200,52 @@ export const reduceFieldsToOptions = ({ } if (typeof fieldTypes[field.type] === 'object') { - const operatorKeys = new Set() - - const fieldOperators = - 'hasMany' in field && field.hasMany ? arrayOperators : fieldTypes[field.type].operators - - const operators = fieldOperators.reduce((acc, operator) => { - if (!operatorKeys.has(operator.value)) { - operatorKeys.add(operator.value) - const operatorKey = `operators:${operator.label}` as ClientTranslationKeys - acc.push({ - ...operator, - label: i18n.t(operatorKey), - }) - } + if ( + fieldPermissions === true || + fieldPermissions?.[field.name] === true || + fieldPermissions?.[field.name]?.read === true + ) { + const operatorKeys = new Set() + + const fieldOperators = + 'hasMany' in field && field.hasMany ? arrayOperators : fieldTypes[field.type].operators + + const operators = fieldOperators.reduce((acc, operator) => { + if (!operatorKeys.has(operator.value)) { + operatorKeys.add(operator.value) + const operatorKey = `operators:${operator.label}` as ClientTranslationKeys + acc.push({ + ...operator, + label: i18n.t(operatorKey), + }) + } - return acc - }, []) + return acc + }, []) - const localizedLabel = getTranslation(field.label || '', i18n) + const localizedLabel = getTranslation(field.label || '', i18n) - const formattedLabel = labelPrefix - ? combineFieldLabel({ - field, - prefix: labelPrefix, - }) - : localizedLabel + const formattedLabel = labelPrefix + ? combineFieldLabel({ + field, + prefix: labelPrefix, + }) + : localizedLabel - const fieldPath = pathPrefix ? createNestedClientFieldPath(pathPrefix, field) : field.name + const fieldPath = pathPrefix ? createNestedClientFieldPath(pathPrefix, field) : field.name - // Check read permissions - skip ID field as it should always be visible - if (fieldPermissions && fieldPath && !fieldIsID(field)) { - const hasReadPermission = hasFieldReadPermission(fieldPermissions, fieldPath) - if (!hasReadPermission) { - return reduced + const formattedField: ReducedField = { + label: formattedLabel, + plainTextLabel: `${labelPrefix ? labelPrefix + ' > ' : ''}${localizedLabel}`, + value: fieldPath, + ...fieldTypes[field.type], + field, + operators, } - } - const formattedField: ReducedField = { - label: formattedLabel, - plainTextLabel: `${labelPrefix ? labelPrefix + ' > ' : ''}${localizedLabel}`, - value: fieldPath, - ...fieldTypes[field.type], - field, - operators, + reduced.push(formattedField) + return reduced } - - reduced.push(formattedField) - return reduced } return reduced }, []) diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index 24107174902..4a7b945762b 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -36,7 +36,7 @@ import { Table, // eslint-disable-next-line payload/no-imports-from-exports-dir -- these MUST reference the exports dir: https://github.com/payloadcms/payload/issues/12002#issuecomment-2791493587 } from '../exports/client/index.js' -import { filterFields } from '../providers/TableColumns/buildColumnState/filterFields.js' +import { filterFieldsWithPermissions } from '../providers/TableColumns/buildColumnState/filterFieldsWithPermissions.js' import { buildColumnState } from '../providers/TableColumns/buildColumnState/index.js' export const renderFilters = ( @@ -133,7 +133,10 @@ export const renderTable = ({ (each) => each.slug === collection, ) - for (const field of filterFields(clientCollectionConfig.fields)) { + for (const field of filterFieldsWithPermissions({ + fieldPermissions, + fields: clientCollectionConfig.fields, + })) { if (fieldAffectsData(field)) { if (clientFields.some((each) => fieldAffectsData(each) && each.name === field.name)) { continue @@ -145,7 +148,10 @@ export const renderTable = ({ const serverCollectionConfig = payload.collections[collection].config - for (const field of filterFields(serverCollectionConfig.fields)) { + for (const field of filterFieldsWithPermissions({ + fieldPermissions, + fields: serverCollectionConfig.fields, + })) { if (fieldAffectsData(field)) { if (serverFields.some((each) => fieldAffectsData(each) && each.name === field.name)) { continue From 4082a5e9a15ae1abb8da13482feef44e1d751692 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 10 Oct 2025 16:47:15 -0400 Subject: [PATCH 5/6] fix: finishes filterFieldsWithPerms --- .../buildColumnState/filterFieldsWithPermissions.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx b/packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx index c7db95f4415..60ea575810a 100644 --- a/packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx +++ b/packages/ui/src/providers/TableColumns/buildColumnState/filterFieldsWithPermissions.tsx @@ -67,7 +67,11 @@ export const filterFieldsWithPermissions = ({ return acc } - if (fieldAffectsData(field) && fieldPermissions[field.name] === true) { + if (fieldAffectsData(field)) { + if (fieldPermissions[field.name] === true || fieldPermissions[field.name]?.read) { + acc.push(field) + } + return acc } // leaf From 50b0b14a22536e51ec6d6a16f4549effd6234cd9 Mon Sep 17 00:00:00 2001 From: Jarrod Flesch Date: Fri, 10 Oct 2025 16:49:18 -0400 Subject: [PATCH 6/6] rm file --- .../hasFieldReadPermission.ts | 57 ------------------- 1 file changed, 57 deletions(-) delete mode 100644 packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts diff --git a/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts b/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts deleted file mode 100644 index 2ea6d11ddf4..00000000000 --- a/packages/ui/src/providers/TableColumns/buildColumnState/hasFieldReadPermission.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { SanitizedFieldsPermissions } from 'payload' - -/** - * Check if a field has read permission by traversing the permissions structure using a dot-notation path. - * Handles both top-level fields (e.g., "restrictedField") and nested fields (e.g., "group.restrictedGroupText"). - * - * The permissions object only contains fields the user can access (fields with read: false are deleted). - * - If permissions is true, all fields are readable - * - If field not in permissions, it was filtered out (not readable) - * - If field in permissions with read: true, it's readable - * - Otherwise, not readable - */ -export const hasFieldReadPermission = ( - permissions: SanitizedFieldsPermissions, - path: string, -): boolean => { - if (permissions === true) { - return true - } - if (typeof permissions !== 'object') { - return false - } - - const pathParts = path.split('.') - - const checkPermission = (currentPerms: any, parts: string[], index: number = 0): boolean => { - const part = parts[index] - const isLastPart = index === parts.length - 1 - - // Field doesn't exist in permissions - was filtered out - if (!(part in currentPerms)) { - return false - } - - const fieldPerm = currentPerms[part] - - // Permission is explicitly true - if (fieldPerm === true) { - return true - } - - // At the last part - check for explicit read: true - if (isLastPart) { - return typeof fieldPerm === 'object' && fieldPerm.read === true - } - - // Need to navigate deeper - check if nested fields exist - if (typeof fieldPerm === 'object' && fieldPerm.fields) { - return checkPermission(fieldPerm.fields, parts, index + 1) - } - - // Can't navigate deeper - nested fields were filtered out - return false - } - - return checkPermission(permissions, pathParts) -}