diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index 8b61bfa3bb..6983dd0886 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -70,7 +70,7 @@ _\* An asterisk denotes that a property is required._ ### filterOptions -Used to dynamically filter which options are available based on the user, data, etc. +Used to dynamically filter which options are available based on the current user, document data, or other criteria. Some examples of this might include: diff --git a/docs/query-presets/overview.mdx b/docs/query-presets/overview.mdx index a5ca006833..e35577a9b4 100644 --- a/docs/query-presets/overview.mdx +++ b/docs/query-presets/overview.mdx @@ -46,11 +46,12 @@ const config = buildConfig({ The following options are available for Query Presets: -| Option | Description | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `access` | Used to define custom collection-level access control that applies to all presets. [More details](#access-control). | -| `constraints` | Used to define custom document-level access control that apply to individual presets. [More details](#document-access-control). | -| `labels` | Custom labels to use for the Query Presets collection. | +| Option | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `access` | Used to define custom collection-level access control that applies to all presets. [More details](#access-control). | +| `filterConstraints` | Used to define which constraints are available to users when managing presets. [More details](#constraint-access-control). | +| `constraints` | Used to define custom document-level access control that apply to individual presets. [More details](#document-access-control). | +| `labels` | Custom labels to use for the Query Presets collection. | ## Access Control @@ -59,7 +60,7 @@ Query Presets are subject to the same [Access Control](../access-control/overvie Access Control for Query Presets can be customized in two ways: 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. -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. +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. ### Collection Access Control @@ -97,7 +98,7 @@ This example restricts all Query Presets to users with the role of `admin`. ### Document Access Control -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. +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. When a user manages a preset, document-level access control options will be available to them in the Admin Panel for each operation. @@ -150,8 +151,8 @@ const config = buildConfig({ }), }, ], - // highlight-end }, + // highlight-end }, }) ``` @@ -171,3 +172,39 @@ The following options are available for each constraint: | `value` | The value to store in the database when this constraint is selected. | | `fields` | An array of fields to render when this constraint is selected. | | `access` | A function that determines the access control rules for this constraint. | + +### Constraint Access Control + +Used to dynamically filter which constraints are available based on the current user, document data, or other criteria. + +Some examples of this might include: + +- Ensuring that only "admins" are allowed to make a preset available to "everyone" +- Preventing the "onlyMe" option from being selected based on a hypothetical "disablePrivatePresets" checkbox + +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. + +To do this, you can use the `filterConstraints` property in your [Payload Config](../configuration/overview): + +```ts +import { buildConfig } from 'payload' + +const config = buildConfig({ + // ... + queryPresets: { + // ... + // highlight-start + filterConstraints: ({ req, options }) => + !req.user?.roles?.includes('admin') + ? options.filter( + (option) => + (typeof option === 'string' ? option : option.value) !== + 'everyone', + ) + : options, + // highlight-end + }, +}) +``` + +The `filterConstraints` function receives the same arguments as [`filterOptions`](../fields/select#filterOptions) in the [Select field](../fields/select). diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 999d1a936b..af5556b235 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -48,6 +48,7 @@ import type { JobsConfig, Payload, RequestContext, + SelectField, TypedUser, } from '../index.js' import type { QueryPreset, QueryPresetConstraints } from '../query-presets/types.js' @@ -1131,6 +1132,7 @@ export type Config = { read?: QueryPresetConstraints update?: QueryPresetConstraints } + filterConstraints?: SelectField['filterOptions'] labels?: CollectionConfig['labels'] } /** Control the routing structure that Payload binds itself to. */ diff --git a/packages/payload/src/query-presets/constraints.ts b/packages/payload/src/query-presets/constraints.ts index f13d9ef9e6..383274a6aa 100644 --- a/packages/payload/src/query-presets/constraints.ts +++ b/packages/payload/src/query-presets/constraints.ts @@ -1,12 +1,28 @@ import { getTranslation } from '@payloadcms/translations' import type { Config } from '../config/types.js' -import type { Field } from '../fields/config/types.js' +import type { Field, Option } from '../fields/config/types.js' +import type { QueryPresetConstraint } from './types.js' import { fieldAffectsData } from '../fields/config/types.js' import { toWords } from '../utilities/formatLabels.js' import { preventLockout } from './preventLockout.js' -import { operations, type QueryPresetConstraint } from './types.js' +import { operations } from './types.js' + +const defaultConstraintOptions: Option[] = [ + { + label: 'Everyone', + value: 'everyone', + }, + { + label: 'Only Me', + value: 'onlyMe', + }, + { + label: 'Specific Users', + value: 'specificUsers', + }, +] export const getConstraints = (config: Config): Field => ({ name: 'access', @@ -17,11 +33,11 @@ export const getConstraints = (config: Config): Field => ({ }, condition: (data) => Boolean(data?.isShared), }, - fields: operations.map((operation) => ({ + fields: operations.map((constraintOperation) => ({ type: 'collapsible', fields: [ { - name: operation, + name: constraintOperation, type: 'group', admin: { hideGutter: true, @@ -31,22 +47,15 @@ export const getConstraints = (config: Config): Field => ({ name: 'constraint', type: 'select', defaultValue: 'onlyMe', + filterOptions: (args) => + typeof config?.queryPresets?.filterConstraints === 'function' + ? config.queryPresets.filterConstraints(args) + : args.options, label: ({ i18n }) => - `Specify who can ${operation} this ${getTranslation(config.queryPresets?.labels?.singular || 'Preset', i18n)}`, + `Specify who can ${constraintOperation} this ${getTranslation(config.queryPresets?.labels?.singular || 'Preset', i18n)}`, options: [ - { - label: 'Everyone', - value: 'everyone', - }, - { - label: 'Only Me', - value: 'onlyMe', - }, - { - label: 'Specific Users', - value: 'specificUsers', - }, - ...(config?.queryPresets?.constraints?.[operation]?.map( + ...defaultConstraintOptions, + ...(config?.queryPresets?.constraints?.[constraintOperation]?.map( (option: QueryPresetConstraint) => ({ label: option.label, value: option.value, @@ -59,27 +68,28 @@ export const getConstraints = (config: Config): Field => ({ type: 'relationship', admin: { condition: (data) => - Boolean(data?.access?.[operation]?.constraint === 'specificUsers'), + Boolean(data?.access?.[constraintOperation]?.constraint === 'specificUsers'), }, hasMany: true, hooks: { beforeChange: [ ({ data, req }) => { - if (data?.access?.[operation]?.constraint === 'onlyMe' && req.user) { + if (data?.access?.[constraintOperation]?.constraint === 'onlyMe' && req.user) { return [req.user.id] } - if (data?.access?.[operation]?.constraint === 'specificUsers' && req.user) { - return [...(data?.access?.[operation]?.users || []), req.user.id] + if ( + data?.access?.[constraintOperation]?.constraint === 'specificUsers' && + req.user + ) { + return [...(data?.access?.[constraintOperation]?.users || []), req.user.id] } - - return data?.access?.[operation]?.users }, ], }, relationTo: config.admin?.user ?? 'users', // TODO: remove this fallback when the args are properly typed as `SanitizedConfig` }, - ...(config?.queryPresets?.constraints?.[operation]?.reduce( + ...(config?.queryPresets?.constraints?.[constraintOperation]?.reduce( (acc: Field[], option: QueryPresetConstraint) => { option.fields?.forEach((field, index) => { acc.push({ ...field }) @@ -88,7 +98,7 @@ export const getConstraints = (config: Config): Field => ({ acc[index].admin = { ...(acc[index]?.admin || {}), condition: (data) => - Boolean(data?.access?.[operation]?.constraint === option.value), + Boolean(data?.access?.[constraintOperation]?.constraint === option.value), } } }) @@ -101,7 +111,7 @@ export const getConstraints = (config: Config): Field => ({ label: false, }, ], - label: () => toWords(operation), + label: () => toWords(constraintOperation), })), label: 'Sharing settings', validate: preventLockout, diff --git a/packages/payload/src/query-presets/types.ts b/packages/payload/src/query-presets/types.ts index 722a2fc6e2..05b5307b7f 100644 --- a/packages/payload/src/query-presets/types.ts +++ b/packages/payload/src/query-presets/types.ts @@ -6,12 +6,16 @@ import type { Where } from '../types/index.js' // Note: order matters here as it will change the rendered order in the UI export const operations = ['read', 'update', 'delete'] as const -type Operation = (typeof operations)[number] +export type ConstraintOperation = (typeof operations)[number] + +export type DefaultConstraint = 'everyone' | 'onlyMe' | 'specificUsers' + +export type Constraint = DefaultConstraint | string // TODO: type `string` as the custom constraints provided by the config export type QueryPreset = { access: { - [operation in Operation]: { - constraint: 'everyone' | 'onlyMe' | 'specificUsers' + [operation in ConstraintOperation]: { + constraint: DefaultConstraint users?: string[] } } diff --git a/test/query-presets/config.ts b/test/query-presets/config.ts index 294549b952..8658e1774a 100644 --- a/test/query-presets/config.ts +++ b/test/query-presets/config.ts @@ -11,6 +11,7 @@ import { seed } from './seed.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) +// eslint-disable-next-line no-restricted-exports export default buildConfigWithDefaults({ admin: { importMap: { @@ -26,6 +27,12 @@ export default buildConfigWithDefaults({ read: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')), update: ({ req: { user } }) => Boolean(user?.roles?.length && !user?.roles?.includes('user')), }, + filterConstraints: ({ req, options }) => + !req.user?.roles?.includes('admin') + ? options.filter( + (option) => (typeof option === 'string' ? option : option.value) !== 'onlyAdmins', + ) + : options, constraints: { read: [ { @@ -43,6 +50,11 @@ export default buildConfigWithDefaults({ value: 'noone', access: () => false, }, + { + label: 'Only Admins', + value: 'onlyAdmins', + access: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')), + }, ], update: [ { @@ -55,6 +67,11 @@ export default buildConfigWithDefaults({ }, }), }, + { + label: 'Only Admins', + value: 'onlyAdmins', + access: ({ req: { user } }) => Boolean(user?.roles?.includes('admin')), + }, ], }, }, diff --git a/test/query-presets/int.spec.ts b/test/query-presets/int.spec.ts index d72bbbf53f..5ce5e4d99c 100644 --- a/test/query-presets/int.spec.ts +++ b/test/query-presets/int.spec.ts @@ -545,6 +545,89 @@ describe('Query Presets', () => { } }) + it('should only allow admins to select the "onlyAdmins" preset (via `filterOptions`)', async () => { + try { + const presetForAdminsCreatedByEditor = await payload.create({ + collection: queryPresetsCollectionSlug, + user: editorUser, + overrideAccess: false, + data: { + title: 'Admins (Created by Editor)', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'onlyAdmins', + }, + update: { + constraint: 'onlyAdmins', + }, + }, + relatedCollection: 'pages', + }, + }) + + expect(presetForAdminsCreatedByEditor).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe( + 'The following fields are invalid: Sharing settings > Read > Specify who can read this Preset, Sharing settings > Update > Specify who can update this Preset', + ) + } + + const presetForAdminsCreatedByAdmin = await payload.create({ + collection: queryPresetsCollectionSlug, + user: adminUser, + overrideAccess: false, + data: { + title: 'Admins (Created by Admin)', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'onlyAdmins', + }, + update: { + constraint: 'onlyAdmins', + }, + }, + relatedCollection: 'pages', + }, + }) + + expect(presetForAdminsCreatedByAdmin).toBeDefined() + + // attempt to update the preset using an editor user + try { + const presetUpdatedByEditorUser = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForAdminsCreatedByAdmin.id, + user: editorUser, + overrideAccess: false, + data: { + title: 'From `onlyAdmins` to `onlyMe` (Updated by Editor)', + access: { + read: { + constraint: 'onlyMe', + }, + update: { + constraint: 'onlyMe', + }, + }, + }, + }) + + expect(presetUpdatedByEditorUser).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + } + }) + it('should respect access when set to "specificRoles"', async () => { const presetForSpecificRoles = await payload.create({ collection: queryPresetsCollectionSlug, diff --git a/test/query-presets/payload-types.ts b/test/query-presets/payload-types.ts index 61c9398cac..b1c23df8e6 100644 --- a/test/query-presets/payload-types.ts +++ b/test/query-presets/payload-types.ts @@ -227,12 +227,12 @@ export interface PayloadQueryPreset { isShared?: boolean | null; access?: { read?: { - constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone') | null; + constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'noone' | 'onlyAdmins') | null; users?: (string | User)[] | null; roles?: ('admin' | 'editor' | 'user')[] | null; }; update?: { - constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null; + constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles' | 'onlyAdmins') | null; users?: (string | User)[] | null; roles?: ('admin' | 'editor' | 'user')[] | null; }; diff --git a/tsconfig.base.json b/tsconfig.base.json index 8d0bb793bc..451c9cfdae 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], + "@payload-config": ["./test/query-presets/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],