Skip to content

Commit 0204f0d

Browse files
authored
feat: filter query preset constraints (#12485)
You can now specify exactly who can change the constraints within a query preset. For example, you want to ensure that only "admins" are allowed to set a preset to "everyone". To do this, you can use the new `queryPresets.filterConstraints` property. When a user lacks the permission to change a constraint, the option will either be hidden from them or disabled if it is already set. ```ts import { buildConfig } from 'payload' const config = buildConfig({ // ... queryPresets: { // ... filterConstraints: ({ req, options }) => !req.user?.roles?.includes('admin') ? options.filter( (option) => (typeof option === 'string' ? option : option.value) !== 'everyone', ) : options, }, }) ``` The `filterConstraints` functions takes the same arguments as `reduceOptions` property on select fields introduced in #12487.
1 parent 032375b commit 0204f0d

File tree

9 files changed

+195
-42
lines changed

9 files changed

+195
-42
lines changed

docs/fields/select.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ _\* An asterisk denotes that a property is required._
7070

7171
### filterOptions
7272

73-
Used to dynamically filter which options are available based on the user, data, etc.
73+
Used to dynamically filter which options are available based on the current user, document data, or other criteria.
7474

7575
Some examples of this might include:
7676

docs/query-presets/overview.mdx

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ const config = buildConfig({
4646

4747
The following options are available for Query Presets:
4848

49-
| Option | Description |
50-
| ------------- | ------------------------------------------------------------------------------------------------------------------------------- |
51-
| `access` | Used to define custom collection-level access control that applies to all presets. [More details](#access-control). |
52-
| `constraints` | Used to define custom document-level access control that apply to individual presets. [More details](#document-access-control). |
53-
| `labels` | Custom labels to use for the Query Presets collection. |
49+
| Option | Description |
50+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
51+
| `access` | Used to define custom collection-level access control that applies to all presets. [More details](#access-control). |
52+
| `filterConstraints` | Used to define which constraints are available to users when managing presets. [More details](#constraint-access-control). |
53+
| `constraints` | Used to define custom document-level access control that apply to individual presets. [More details](#document-access-control). |
54+
| `labels` | Custom labels to use for the Query Presets collection. |
5455

5556
## Access Control
5657

@@ -59,7 +60,7 @@ Query Presets are subject to the same [Access Control](../access-control/overvie
5960
Access Control for Query Presets can be customized in two ways:
6061

6162
1. [Collection Access Control](#collection-access-control): Applies to all presets. These rules are not controllable by the user and are statically defined in the config.
62-
2. [Document Access Control](#document-access-control): Applies to each individual preset. These rules are controllable by the user and are saved to the document.
63+
2. [Document Access Control](#document-access-control): Applies to each individual preset. These rules are controllable by the user and are dynamically defined on each record in the database.
6364

6465
### Collection Access Control
6566

@@ -97,7 +98,7 @@ This example restricts all Query Presets to users with the role of `admin`.
9798

9899
### Document Access Control
99100

100-
You can also define access control rules that apply to each specific preset. Users have the ability to define and modify these rules on the fly as they manage presets. These are saved dynamically in the database on each document.
101+
You can also define access control rules that apply to each specific preset. Users have the ability to define and modify these rules on the fly as they manage presets. These are saved dynamically in the database on each record.
101102

102103
When a user manages a preset, document-level access control options will be available to them in the Admin Panel for each operation.
103104

@@ -150,8 +151,8 @@ const config = buildConfig({
150151
}),
151152
},
152153
],
153-
// highlight-end
154154
},
155+
// highlight-end
155156
},
156157
})
157158
```
@@ -171,3 +172,39 @@ The following options are available for each constraint:
171172
| `value` | The value to store in the database when this constraint is selected. |
172173
| `fields` | An array of fields to render when this constraint is selected. |
173174
| `access` | A function that determines the access control rules for this constraint. |
175+
176+
### Constraint Access Control
177+
178+
Used to dynamically filter which constraints are available based on the current user, document data, or other criteria.
179+
180+
Some examples of this might include:
181+
182+
- Ensuring that only "admins" are allowed to make a preset available to "everyone"
183+
- Preventing the "onlyMe" option from being selected based on a hypothetical "disablePrivatePresets" checkbox
184+
185+
When a user lacks the permission to set a constraint, the option will either be hidden from them, or disabled if it is already saved to that preset.
186+
187+
To do this, you can use the `filterConstraints` property in your [Payload Config](../configuration/overview):
188+
189+
```ts
190+
import { buildConfig } from 'payload'
191+
192+
const config = buildConfig({
193+
// ...
194+
queryPresets: {
195+
// ...
196+
// highlight-start
197+
filterConstraints: ({ req, options }) =>
198+
!req.user?.roles?.includes('admin')
199+
? options.filter(
200+
(option) =>
201+
(typeof option === 'string' ? option : option.value) !==
202+
'everyone',
203+
)
204+
: options,
205+
// highlight-end
206+
},
207+
})
208+
```
209+
210+
The `filterConstraints` function receives the same arguments as [`filterOptions`](../fields/select#filterOptions) in the [Select field](../fields/select).

packages/payload/src/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import type {
4848
JobsConfig,
4949
Payload,
5050
RequestContext,
51+
SelectField,
5152
TypedUser,
5253
} from '../index.js'
5354
import type { QueryPreset, QueryPresetConstraints } from '../query-presets/types.js'
@@ -1131,6 +1132,7 @@ export type Config = {
11311132
read?: QueryPresetConstraints
11321133
update?: QueryPresetConstraints
11331134
}
1135+
filterConstraints?: SelectField['filterOptions']
11341136
labels?: CollectionConfig['labels']
11351137
}
11361138
/** Control the routing structure that Payload binds itself to. */

packages/payload/src/query-presets/constraints.ts

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import { getTranslation } from '@payloadcms/translations'
22

33
import type { Config } from '../config/types.js'
4-
import type { Field } from '../fields/config/types.js'
4+
import type { Field, Option } from '../fields/config/types.js'
5+
import type { QueryPresetConstraint } from './types.js'
56

67
import { fieldAffectsData } from '../fields/config/types.js'
78
import { toWords } from '../utilities/formatLabels.js'
89
import { preventLockout } from './preventLockout.js'
9-
import { operations, type QueryPresetConstraint } from './types.js'
10+
import { operations } from './types.js'
11+
12+
const defaultConstraintOptions: Option[] = [
13+
{
14+
label: 'Everyone',
15+
value: 'everyone',
16+
},
17+
{
18+
label: 'Only Me',
19+
value: 'onlyMe',
20+
},
21+
{
22+
label: 'Specific Users',
23+
value: 'specificUsers',
24+
},
25+
]
1026

1127
export const getConstraints = (config: Config): Field => ({
1228
name: 'access',
@@ -17,11 +33,11 @@ export const getConstraints = (config: Config): Field => ({
1733
},
1834
condition: (data) => Boolean(data?.isShared),
1935
},
20-
fields: operations.map((operation) => ({
36+
fields: operations.map((constraintOperation) => ({
2137
type: 'collapsible',
2238
fields: [
2339
{
24-
name: operation,
40+
name: constraintOperation,
2541
type: 'group',
2642
admin: {
2743
hideGutter: true,
@@ -31,22 +47,15 @@ export const getConstraints = (config: Config): Field => ({
3147
name: 'constraint',
3248
type: 'select',
3349
defaultValue: 'onlyMe',
50+
filterOptions: (args) =>
51+
typeof config?.queryPresets?.filterConstraints === 'function'
52+
? config.queryPresets.filterConstraints(args)
53+
: args.options,
3454
label: ({ i18n }) =>
35-
`Specify who can ${operation} this ${getTranslation(config.queryPresets?.labels?.singular || 'Preset', i18n)}`,
55+
`Specify who can ${constraintOperation} this ${getTranslation(config.queryPresets?.labels?.singular || 'Preset', i18n)}`,
3656
options: [
37-
{
38-
label: 'Everyone',
39-
value: 'everyone',
40-
},
41-
{
42-
label: 'Only Me',
43-
value: 'onlyMe',
44-
},
45-
{
46-
label: 'Specific Users',
47-
value: 'specificUsers',
48-
},
49-
...(config?.queryPresets?.constraints?.[operation]?.map(
57+
...defaultConstraintOptions,
58+
...(config?.queryPresets?.constraints?.[constraintOperation]?.map(
5059
(option: QueryPresetConstraint) => ({
5160
label: option.label,
5261
value: option.value,
@@ -59,27 +68,28 @@ export const getConstraints = (config: Config): Field => ({
5968
type: 'relationship',
6069
admin: {
6170
condition: (data) =>
62-
Boolean(data?.access?.[operation]?.constraint === 'specificUsers'),
71+
Boolean(data?.access?.[constraintOperation]?.constraint === 'specificUsers'),
6372
},
6473
hasMany: true,
6574
hooks: {
6675
beforeChange: [
6776
({ data, req }) => {
68-
if (data?.access?.[operation]?.constraint === 'onlyMe' && req.user) {
77+
if (data?.access?.[constraintOperation]?.constraint === 'onlyMe' && req.user) {
6978
return [req.user.id]
7079
}
7180

72-
if (data?.access?.[operation]?.constraint === 'specificUsers' && req.user) {
73-
return [...(data?.access?.[operation]?.users || []), req.user.id]
81+
if (
82+
data?.access?.[constraintOperation]?.constraint === 'specificUsers' &&
83+
req.user
84+
) {
85+
return [...(data?.access?.[constraintOperation]?.users || []), req.user.id]
7486
}
75-
76-
return data?.access?.[operation]?.users
7787
},
7888
],
7989
},
8090
relationTo: config.admin?.user ?? 'users', // TODO: remove this fallback when the args are properly typed as `SanitizedConfig`
8191
},
82-
...(config?.queryPresets?.constraints?.[operation]?.reduce(
92+
...(config?.queryPresets?.constraints?.[constraintOperation]?.reduce(
8393
(acc: Field[], option: QueryPresetConstraint) => {
8494
option.fields?.forEach((field, index) => {
8595
acc.push({ ...field })
@@ -88,7 +98,7 @@ export const getConstraints = (config: Config): Field => ({
8898
acc[index].admin = {
8999
...(acc[index]?.admin || {}),
90100
condition: (data) =>
91-
Boolean(data?.access?.[operation]?.constraint === option.value),
101+
Boolean(data?.access?.[constraintOperation]?.constraint === option.value),
92102
}
93103
}
94104
})
@@ -101,7 +111,7 @@ export const getConstraints = (config: Config): Field => ({
101111
label: false,
102112
},
103113
],
104-
label: () => toWords(operation),
114+
label: () => toWords(constraintOperation),
105115
})),
106116
label: 'Sharing settings',
107117
validate: preventLockout,

packages/payload/src/query-presets/types.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@ import type { Where } from '../types/index.js'
66
// Note: order matters here as it will change the rendered order in the UI
77
export const operations = ['read', 'update', 'delete'] as const
88

9-
type Operation = (typeof operations)[number]
9+
export type ConstraintOperation = (typeof operations)[number]
10+
11+
export type DefaultConstraint = 'everyone' | 'onlyMe' | 'specificUsers'
12+
13+
export type Constraint = DefaultConstraint | string // TODO: type `string` as the custom constraints provided by the config
1014

1115
export type QueryPreset = {
1216
access: {
13-
[operation in Operation]: {
14-
constraint: 'everyone' | 'onlyMe' | 'specificUsers'
17+
[operation in ConstraintOperation]: {
18+
constraint: DefaultConstraint
1519
users?: string[]
1620
}
1721
}

test/query-presets/config.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { seed } from './seed.js'
1111
const filename = fileURLToPath(import.meta.url)
1212
const dirname = path.dirname(filename)
1313

14+
// eslint-disable-next-line no-restricted-exports
1415
export default buildConfigWithDefaults({
1516
admin: {
1617
importMap: {
@@ -26,6 +27,12 @@ export default buildConfigWithDefaults({
2627
read: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')),
2728
update: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')),
2829
},
30+
filterConstraints: ({ req, options }) =>
31+
!req.user?.roles?.includes('admin')
32+
? options.filter(
33+
(option) => (typeof option === 'string' ? option : option.value) !== 'onlyAdmins',
34+
)
35+
: options,
2936
constraints: {
3037
read: [
3138
{
@@ -43,6 +50,11 @@ export default buildConfigWithDefaults({
4350
value: 'noone',
4451
access: () => false,
4552
},
53+
{
54+
label: 'Only Admins',
55+
value: 'onlyAdmins',
56+
access: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
57+
},
4658
],
4759
update: [
4860
{
@@ -55,6 +67,11 @@ export default buildConfigWithDefaults({
5567
},
5668
}),
5769
},
70+
{
71+
label: 'Only Admins',
72+
value: 'onlyAdmins',
73+
access: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')),
74+
},
5875
],
5976
},
6077
},

test/query-presets/int.spec.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,89 @@ describe('Query Presets', () => {
545545
}
546546
})
547547

548+
it('should only allow admins to select the "onlyAdmins" preset (via `filterOptions`)', async () => {
549+
try {
550+
const presetForAdminsCreatedByEditor = await payload.create({
551+
collection: queryPresetsCollectionSlug,
552+
user: editorUser,
553+
overrideAccess: false,
554+
data: {
555+
title: 'Admins (Created by Editor)',
556+
where: {
557+
text: {
558+
equals: 'example page',
559+
},
560+
},
561+
access: {
562+
read: {
563+
constraint: 'onlyAdmins',
564+
},
565+
update: {
566+
constraint: 'onlyAdmins',
567+
},
568+
},
569+
relatedCollection: 'pages',
570+
},
571+
})
572+
573+
expect(presetForAdminsCreatedByEditor).toBeFalsy()
574+
} catch (error: unknown) {
575+
expect((error as Error).message).toBe(
576+
'The following fields are invalid: Sharing settings > Read > Specify who can read this Preset, Sharing settings > Update > Specify who can update this Preset',
577+
)
578+
}
579+
580+
const presetForAdminsCreatedByAdmin = await payload.create({
581+
collection: queryPresetsCollectionSlug,
582+
user: adminUser,
583+
overrideAccess: false,
584+
data: {
585+
title: 'Admins (Created by Admin)',
586+
where: {
587+
text: {
588+
equals: 'example page',
589+
},
590+
},
591+
access: {
592+
read: {
593+
constraint: 'onlyAdmins',
594+
},
595+
update: {
596+
constraint: 'onlyAdmins',
597+
},
598+
},
599+
relatedCollection: 'pages',
600+
},
601+
})
602+
603+
expect(presetForAdminsCreatedByAdmin).toBeDefined()
604+
605+
// attempt to update the preset using an editor user
606+
try {
607+
const presetUpdatedByEditorUser = await payload.update({
608+
collection: queryPresetsCollectionSlug,
609+
id: presetForAdminsCreatedByAdmin.id,
610+
user: editorUser,
611+
overrideAccess: false,
612+
data: {
613+
title: 'From `onlyAdmins` to `onlyMe` (Updated by Editor)',
614+
access: {
615+
read: {
616+
constraint: 'onlyMe',
617+
},
618+
update: {
619+
constraint: 'onlyMe',
620+
},
621+
},
622+
},
623+
})
624+
625+
expect(presetUpdatedByEditorUser).toBeFalsy()
626+
} catch (error: unknown) {
627+
expect((error as Error).message).toBe('You are not allowed to perform this action.')
628+
}
629+
})
630+
548631
it('should respect access when set to "specificRoles"', async () => {
549632
const presetForSpecificRoles = await payload.create({
550633
collection: queryPresetsCollectionSlug,

0 commit comments

Comments
 (0)