Skip to content

Commit bcd40b6

Browse files
fix: hide fields with read: false in list view columns, filters, and groupBy (#14118)
### What? Fixes fields with `read: false` access control appearing in list view column selector, filter dropdown, and groupBy dropdown. Previously, these fields would show with `<No Restricted Field>` placeholders in columns, and were still selectable in the filter and groupBy dropdowns despite the user not having read access. ### Why? Users should not be able to interact with fields they don't have read access to. ### How? - Created `filterFieldsWithPermissions` utility that recursively filters fields based on `read` permissions - Handles nested structures (tabs, groups, rows, collapsibles) by recursively accessing nested permissions via `fieldPermissions[fieldName]?.fields` or `fieldPermissions[fieldName]` - Checks `fieldPermissions[field.name] === true` or `fieldPermissions[field.name]?.read` for leaf fields with data - Combines filtering logic from existing `filterFields` with permission checks - Updated `buildColumnState` to use `filterFieldsWithPermissions` instead of `filterFields`, filtering both client and server fields before flattening - Updated `renderTable` to use `filterFieldsWithPermissions` when building field lists for relationship tables --------- Co-authored-by: Jarrod Flesch <[email protected]>
1 parent cacf523 commit bcd40b6

File tree

16 files changed

+1370
-91
lines changed

16 files changed

+1370
-91
lines changed

packages/next/src/views/List/handleGroupBy.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
PaginatedDocs,
77
PayloadRequest,
88
SanitizedCollectionConfig,
9+
SanitizedFieldsPermissions,
910
SelectType,
1011
ViewTypes,
1112
Where,
@@ -28,6 +29,7 @@ export const handleGroupBy = async ({
2829
customCellProps,
2930
drawerSlug,
3031
enableRowSelections,
32+
fieldPermissions,
3133
query,
3234
req,
3335
select,
@@ -44,6 +46,7 @@ export const handleGroupBy = async ({
4446
customCellProps?: Record<string, any>
4547
drawerSlug?: string
4648
enableRowSelections?: boolean
49+
fieldPermissions?: SanitizedFieldsPermissions
4750
query?: ListQuery
4851
req: PayloadRequest
4952
select?: SelectType
@@ -183,6 +186,7 @@ export const handleGroupBy = async ({
183186
data: groupData,
184187
drawerSlug,
185188
enableRowSelections,
189+
fieldPermissions,
186190
groupByFieldPath,
187191
groupByValue: serializableValue,
188192
heading: heading || req.i18n.t('general:noValue'),

packages/next/src/views/List/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export const renderListView = async (
236236
collectionSlug,
237237
columns: collectionPreferences?.columns,
238238
i18n,
239+
permissions,
239240
})
240241

241242
const select = collectionConfig.admin.enableListViewSelectAPI
@@ -259,6 +260,7 @@ export const renderListView = async (
259260
customCellProps,
260261
drawerSlug,
261262
enableRowSelections,
263+
fieldPermissions: permissions?.collections?.[collectionSlug]?.fields,
262264
query,
263265
req,
264266
select,
@@ -293,6 +295,7 @@ export const renderListView = async (
293295
data,
294296
drawerSlug,
295297
enableRowSelections,
298+
fieldPermissions: permissions?.collections?.[collectionSlug]?.fields,
296299
i18n: req.i18n,
297300
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
298301
payload: req.payload,

packages/ui/src/elements/GroupByBuilder/index.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import './index.scss'
66
import React, { useMemo } from 'react'
77

88
import { SelectInput } from '../../fields/Select/Input.js'
9+
import { useAuth } from '../../providers/Auth/index.js'
910
import { useListQuery } from '../../providers/ListQuery/index.js'
1011
import { useTranslation } from '../../providers/Translation/index.js'
1112
import { reduceFieldsToOptions } from '../../utilities/reduceFieldsToOptions.js'
@@ -41,8 +42,19 @@ const supportedFieldTypes: Field['type'][] = [
4142

4243
export const GroupByBuilder: React.FC<Props> = ({ collectionSlug, fields }) => {
4344
const { i18n, t } = useTranslation()
45+
const { permissions } = useAuth()
4446

45-
const reducedFields = useMemo(() => reduceFieldsToOptions({ fields, i18n }), [fields, i18n])
47+
const fieldPermissions = permissions?.collections?.[collectionSlug]?.fields
48+
49+
const reducedFields = useMemo(
50+
() =>
51+
reduceFieldsToOptions({
52+
fieldPermissions,
53+
fields,
54+
i18n,
55+
}),
56+
[fields, fieldPermissions, i18n],
57+
)
4658

4759
const { query, refineListData } = useListQuery()
4860

packages/ui/src/elements/WhereBuilder/index.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import React, { useMemo } from 'react'
77

88
import type { AddCondition, RemoveCondition, UpdateCondition, WhereBuilderProps } from './types.js'
99

10+
import { useAuth } from '../../providers/Auth/index.js'
1011
import { useListQuery } from '../../providers/ListQuery/index.js'
1112
import { useTranslation } from '../../providers/Translation/index.js'
1213
import { reduceFieldsToOptions } from '../../utilities/reduceFieldsToOptions.js'
@@ -24,10 +25,22 @@ export { WhereBuilderProps }
2425
* It is part of the {@link ListControls} component which is used to render the controls (search, filter, where).
2526
*/
2627
export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
27-
const { collectionPluralLabel, fields, renderedFilters, resolvedFilterOptions } = props
28+
const { collectionPluralLabel, collectionSlug, fields, renderedFilters, resolvedFilterOptions } =
29+
props
2830
const { i18n, t } = useTranslation()
29-
30-
const reducedFields = useMemo(() => reduceFieldsToOptions({ fields, i18n }), [fields, i18n])
31+
const { permissions } = useAuth()
32+
33+
const fieldPermissions = permissions?.collections?.[collectionSlug]?.fields
34+
35+
const reducedFields = useMemo(
36+
() =>
37+
reduceFieldsToOptions({
38+
fieldPermissions,
39+
fields,
40+
i18n,
41+
}),
42+
[fieldPermissions, fields, i18n],
43+
)
3144

3245
const { handleWhereChange, query } = useListQuery()
3346

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type {
2+
ClientField,
3+
Field,
4+
SanitizedFieldPermissions,
5+
SanitizedFieldsPermissions,
6+
} from 'payload'
7+
8+
import { fieldAffectsData, fieldIsHiddenOrDisabled, fieldIsID } from 'payload/shared'
9+
10+
const shouldSkipField = (field: ClientField | Field): boolean =>
11+
(field.type !== 'ui' && fieldIsHiddenOrDisabled(field) && !fieldIsID(field)) ||
12+
field?.admin?.disableListColumn === true
13+
14+
export const filterFieldsWithPermissions = <T extends ClientField | Field = ClientField>({
15+
fieldPermissions,
16+
fields,
17+
}: {
18+
fieldPermissions?: SanitizedFieldPermissions | SanitizedFieldsPermissions
19+
fields: (ClientField | Field)[]
20+
}): T[] => {
21+
return (fields ?? []).reduce((acc, field) => {
22+
if (shouldSkipField(field)) {
23+
return acc
24+
}
25+
26+
// handle tabs
27+
if (field.type === 'tabs' && 'tabs' in field) {
28+
const formattedField = {
29+
...field,
30+
tabs: field.tabs.map((tab) => ({
31+
...tab,
32+
fields: filterFieldsWithPermissions<T>({
33+
fieldPermissions:
34+
typeof fieldPermissions === 'boolean'
35+
? fieldPermissions
36+
: 'name' in tab && tab.name
37+
? fieldPermissions[tab.name]?.fields || fieldPermissions[tab.name]
38+
: fieldPermissions,
39+
fields: tab.fields,
40+
}),
41+
})),
42+
} as ClientField | Field
43+
acc.push(formattedField)
44+
return acc
45+
}
46+
47+
// handle fields with subfields (row, group, collapsible, etc.)
48+
if ('fields' in field && Array.isArray(field.fields)) {
49+
const formattedField = {
50+
...field,
51+
fields: filterFieldsWithPermissions<T>({
52+
fieldPermissions:
53+
typeof fieldPermissions === 'boolean'
54+
? fieldPermissions
55+
: 'name' in field && field.name
56+
? fieldPermissions[field.name]?.fields || fieldPermissions[field.name]
57+
: fieldPermissions,
58+
fields: field.fields,
59+
}),
60+
} as ClientField | Field
61+
acc.push(formattedField)
62+
return acc
63+
}
64+
65+
if (fieldPermissions === true) {
66+
acc.push(field)
67+
return acc
68+
}
69+
70+
if (fieldAffectsData(field)) {
71+
const fieldPermission = fieldPermissions[field.name]
72+
// Always allow ID fields, or if explicitly granted (true or { read: true })
73+
// undefined means field is not in permissions object = denied
74+
if (fieldIsID(field) || fieldPermission === true || fieldPermission?.read === true) {
75+
acc.push(field)
76+
}
77+
return acc
78+
}
79+
80+
// leaf
81+
acc.push(field)
82+
return acc
83+
}, [])
84+
}

packages/ui/src/providers/TableColumns/buildColumnState/index.tsx

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
Payload,
1313
PayloadRequest,
1414
SanitizedCollectionConfig,
15+
SanitizedFieldsPermissions,
1516
ServerComponentProps,
1617
StaticLabel,
1718
ViewTypes,
@@ -32,7 +33,7 @@ import {
3233
SortColumn,
3334
// eslint-disable-next-line payload/no-imports-from-exports-dir -- MUST reference the exports dir: https://github.com/payloadcms/payload/issues/12002#issuecomment-2791493587
3435
} from '../../../exports/client/index.js'
35-
import { filterFields } from './filterFields.js'
36+
import { filterFieldsWithPermissions } from './filterFieldsWithPermissions.js'
3637
import { isColumnActive } from './isColumnActive.js'
3738
import { renderCell } from './renderCell.js'
3839
import { sortFieldMap } from './sortFieldMap.js'
@@ -45,6 +46,7 @@ export type BuildColumnStateArgs = {
4546
enableLinkedCell?: boolean
4647
enableRowSelections: boolean
4748
enableRowTypes?: boolean
49+
fieldPermissions?: SanitizedFieldsPermissions
4850
i18n: I18nClient
4951
payload: Payload
5052
req?: PayloadRequest
@@ -79,6 +81,7 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
7981
docs,
8082
enableLinkedCell = true,
8183
enableRowSelections,
84+
fieldPermissions,
8285
i18n,
8386
payload,
8487
req,
@@ -89,17 +92,26 @@ export const buildColumnState = (args: BuildColumnStateArgs): Column[] => {
8992
} = args
9093

9194
// clientFields contains the fake `id` column
92-
let sortedFieldMap = flattenTopLevelFields(filterFields(clientFields), {
93-
i18n,
94-
keepPresentationalFields: true,
95-
moveSubFieldsToTop: true,
96-
}) as ClientField[]
97-
98-
let _sortedFieldMap = flattenTopLevelFields(filterFields(serverFields), {
99-
i18n,
100-
keepPresentationalFields: true,
101-
moveSubFieldsToTop: true,
102-
}) as Field[] // TODO: think of a way to avoid this additional flatten
95+
let sortedFieldMap = flattenTopLevelFields(
96+
filterFieldsWithPermissions({ fieldPermissions, fields: clientFields }),
97+
{
98+
i18n,
99+
keepPresentationalFields: true,
100+
moveSubFieldsToTop: true,
101+
},
102+
) as ClientField[]
103+
104+
let _sortedFieldMap = flattenTopLevelFields(
105+
filterFieldsWithPermissions({
106+
fieldPermissions,
107+
fields: serverFields,
108+
}),
109+
{
110+
i18n,
111+
keepPresentationalFields: true,
112+
moveSubFieldsToTop: true,
113+
},
114+
) as Field[] // TODO: think of a way to avoid this additional flatten
103115

104116
// place the `ID` field first, if it exists
105117
// do the same for the `useAsTitle` field with precedence over the `ID` field

packages/ui/src/utilities/buildTableState.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type {
1111
Where,
1212
} from 'payload'
1313

14-
import { APIError, canAccessAdmin, formatErrors } from 'payload'
14+
import { APIError, canAccessAdmin, formatErrors, getAccessResults } from 'payload'
1515
import { isNumber } from 'payload/shared'
1616

1717
import { getClientConfig } from './getClientConfig.js'
@@ -100,6 +100,8 @@ const buildTableState = async (
100100
user,
101101
})
102102

103+
const permissions = await getAccessResults({ req })
104+
103105
let collectionConfig: SanitizedCollectionConfig
104106
let clientCollectionConfig: ClientCollectionConfig
105107

@@ -205,9 +207,13 @@ const buildTableState = async (
205207
collectionSlug,
206208
columns: columnsFromArgs,
207209
i18n: req.i18n,
210+
permissions,
208211
}),
209212
data,
210213
enableRowSelections,
214+
fieldPermissions: Array.isArray(collectionSlug)
215+
? true
216+
: permissions.collections[collectionSlug].fields,
211217
i18n: req.i18n,
212218
orderableFieldName,
213219
payload,

packages/ui/src/utilities/getColumns.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import type { I18nClient } from '@payloadcms/translations'
2-
import type { ClientCollectionConfig, ClientConfig, ColumnPreference } from 'payload'
2+
import type {
3+
ClientCollectionConfig,
4+
ClientConfig,
5+
ColumnPreference,
6+
SanitizedPermissions,
7+
} from 'payload'
38

49
import { flattenTopLevelFields } from 'payload'
510
import { fieldAffectsData } from 'payload/shared'
611

7-
import { filterFields } from '../providers/TableColumns/buildColumnState/filterFields.js'
12+
import { filterFieldsWithPermissions } from '../providers/TableColumns/buildColumnState/filterFieldsWithPermissions.js'
813
import { getInitialColumns } from '../providers/TableColumns/getInitialColumns.js'
914

1015
export const getColumns = ({
@@ -13,12 +18,14 @@ export const getColumns = ({
1318
collectionSlug,
1419
columns,
1520
i18n,
21+
permissions,
1622
}: {
1723
clientConfig: ClientConfig
1824
collectionConfig?: ClientCollectionConfig
1925
collectionSlug: string | string[]
2026
columns: ColumnPreference[]
2127
i18n: I18nClient
28+
permissions?: SanitizedPermissions
2229
}) => {
2330
const isPolymorphic = Array.isArray(collectionSlug)
2431

@@ -30,7 +37,10 @@ export const getColumns = ({
3037
(each) => each.slug === collection,
3138
)
3239

33-
for (const field of filterFields(clientCollectionConfig.fields)) {
40+
for (const field of filterFieldsWithPermissions({
41+
fieldPermissions: permissions?.collections?.[collection]?.fields || true,
42+
fields: clientCollectionConfig.fields,
43+
})) {
3444
if (fieldAffectsData(field)) {
3545
if (fields.some((each) => fieldAffectsData(each) && each.name === field.name)) {
3646
continue
@@ -55,7 +65,16 @@ export const getColumns = ({
5565
}),
5666
)
5767
: getInitialColumns(
58-
isPolymorphic ? fields : filterFields(fields),
68+
isPolymorphic
69+
? fields
70+
: filterFieldsWithPermissions({
71+
fieldPermissions:
72+
typeof collectionSlug === 'string' &&
73+
permissions?.collections?.[collectionSlug]?.fields
74+
? permissions.collections[collectionSlug].fields
75+
: true,
76+
fields,
77+
}),
5978
collectionConfig?.admin?.useAsTitle,
6079
isPolymorphic ? [] : collectionConfig?.admin?.defaultColumns,
6180
)

0 commit comments

Comments
 (0)