diff --git a/.changeset/12082-affordance-grant-map.md b/.changeset/12082-affordance-grant-map.md new file mode 100644 index 0000000000..d98a7aed2f --- /dev/null +++ b/.changeset/12082-affordance-grant-map.md @@ -0,0 +1,77 @@ +--- +'@object-ui/core': minor +'@object-ui/permissions': minor +'@object-ui/plugin-form': patch +'@object-ui/app-shell': patch +'@object-ui/fields': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-detail': patch +'@object-ui/console': patch +--- + +A create form asks its fields the create question, so a role that may create +records but not edit them can fill and submit the form (objectui#12082). Every +console affordance that offers a write now reads the grant it exercises from +one map. + +**The defect.** A create form gated each field on `checkField(object, field, +'write')`, whose fallback for a field the permission set does not mention is +the object's `allowEdit`. Under a grant of `allowCreate: true, allowEdit: false` +every field of the create form rendered disabled, the outbound filter stripped +every field from the body, and the save posted an empty record that the server +refused for its required fields — while the server accepts the same create. + +**The server's insert rule, which the create question follows.** The server's +field-level write step refuses a write that names a field whose explicit +field-level entry has `editable: false`; a field with no entry passes it, and +object admission decides the operation (`allowCreate` for an insert, +`allowEdit` for an update). So a create-form field now reads its explicit entry +when there is one and the object's create grant when there is none. A field the +permission set marks `editable: false` stays disabled and out of the body. + +**Clause-②: yes (widening)** + +- `@object-ui/core` exports the affordance-to-grant map: `AFFORDANCE_GRANTS` + (one row per affordance: the CRUD-affordance bit it needs, the object grant it + exercises and, for an affordance that offers fields, the field question it + asks), `resolveAffordance` (managed-object policy ∧ the server's effective API + operation set ∧ the caller's grant, with the row's `userActions` predicates + surfaced only when all three allow it), `resolveFieldAffordance`, + `formFieldsAffordance`, and their types (`ConsoleAffordance`, + `FieldAffordance`, `AffordanceGrant`, `FieldAffordanceGrant`, + `AffordanceGrantRow`, `AffordanceGrantPrincipal`, `FieldAffordancePrincipal`, + `AffordanceSource`, `AffordanceVerdict`). +- `@object-ui/permissions`: `checkField`'s action accepts `'create'` beside + `'read'` and `'write'`. `MePermissionsProvider` answers it from the explicit + field entry when there is one and from `allowCreate` otherwise; the + role-based `PermissionProvider` answers it as it answers `'write'`. + +**Behaviour, by package.** With no permission provider mounted every grant +still reads open, as before. + +- `@object-ui/plugin-form`: every `ObjectForm` layout's fields and outbound + filter ask the question of the form's mode (create or edit). The form-wide + lock, with its "You don't have permission to …" notice, also engages when the + caller's object grant for the form's mode is denied, not only when the + managed-object policy or the effective API operation set closes it. A + create-mode `MasterDetailForm`'s line cells ask the create question of the + child object, since every line there is a new record. +- `@object-ui/app-shell`: the record page's Edit and Delete (and the record + body's in-place editing) read the caller's update / delete grant; they read + none before. The import wizard's write targets ask the create question, so a + caller offered Import keeps every field the insert accepts. List New / Import, + the related lists and the Attachments panel read the map with the verdicts + they had. +- `@object-ui/fields`: a lookup's "Create new" reads the create grant (and the + managed-object policy and operation set) of the object the field references; + it read no grant before. +- `@object-ui/plugin-grid`: row Edit / Delete, in-place editing, the template + download and the add-record row read the map; the add-record row now also + honours the object's managed-object policy and effective `create` operation. +- `@object-ui/plugin-detail`: `record:details` in-place editing reads the + caller's update grant; the detail header's object gate adds the effective + operation set. +- `@object-ui/plugin-list` and `@object-ui/console`: bulk Delete, the + inline-edit toggle and the profile page's language field read the map with + the verdicts they had. diff --git a/apps/console/src/pages/system/ProfilePage.tsx b/apps/console/src/pages/system/ProfilePage.tsx index f1d73f080a..77fea3046d 100644 --- a/apps/console/src/pages/system/ProfilePage.tsx +++ b/apps/console/src/pages/system/ProfilePage.tsx @@ -39,6 +39,7 @@ import { useUpload } from '@object-ui/providers'; import { useObjectTranslation, type TranslateFn } from '@object-ui/i18n'; import { useAdapter, extractFieldErrors, extractWriteErrorMessage } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; +import { resolveFieldAffordance } from '@object-ui/core'; import { CheckCircle2, AlertCircle, User, Lock, Upload, Loader2, X, Globe } from 'lucide-react'; /** @@ -461,9 +462,11 @@ interface LanguageCardProps { * truth rather than rendering blank. * * Availability is asked, not discovered from a rejection: the card renders - * nothing until the row has been read, and `checkField('sys_user', 'locale', - * 'write')` — which consults field-level permissions and falls back to the - * object gate's `allowEdit` — decides between an editable control and a + * nothing until the row has been read, and the field question an edit asks — + * the `editFormFields` row of the affordance-to-grant map, read through + * `resolveFieldAffordance` from `@object-ui/core` (objectui#12082): it consults + * field-level permissions and falls back to the object gate's `allowEdit` — + * decides between an editable control and a * read-only one carrying the reason. A failed read hides the card, the same * "render nothing rather than something you will have to retract" idiom * `LocaleSwitcher` uses for a locale list it does not have yet. That is the @@ -472,7 +475,7 @@ interface LanguageCardProps { function LanguageCard({ userId }: LanguageCardProps) { const { t, offerableLanguages } = useObjectTranslation(); const adapter = useAdapter(); - const { checkField } = usePermissions(); + const perms = usePermissions(); const [stored, setStored] = useState(null); const [choice, setChoice] = useState(USE_DEPLOYMENT_DEFAULT); @@ -519,7 +522,7 @@ function LanguageCard({ userId }: LanguageCardProps) { if (!adapter || !rowRead || offerableLanguages === null) return null; - const writable = checkField('sys_user', 'locale', 'write'); + const writable = resolveFieldAffordance('editFormFields', perms, 'sys_user', 'locale'); const dirty = choice !== (stored ?? USE_DEPLOYMENT_DEFAULT); const handleSave = async (e: React.FormEvent) => { diff --git a/apps/console/src/pages/system/__tests__/ProfilePage.access.test.tsx b/apps/console/src/pages/system/__tests__/ProfilePage.access.test.tsx index 8abb2476e4..9ddddbe55b 100644 --- a/apps/console/src/pages/system/__tests__/ProfilePage.access.test.tsx +++ b/apps/console/src/pages/system/__tests__/ProfilePage.access.test.tsx @@ -60,7 +60,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ vi.mock('@object-ui/permissions', async (importOriginal) => ({ ...(await importOriginal>()), - usePermissions: () => ({ checkField: () => true }), + usePermissions: () => ({ isLoaded: true, checkField: () => true }), })); const { ProfilePage } = await import('../ProfilePage'); diff --git a/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx b/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx index e651e3b7fd..fb227bb49a 100644 --- a/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx +++ b/apps/console/src/pages/system/__tests__/ProfilePage.language.test.tsx @@ -115,7 +115,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ vi.mock('@object-ui/permissions', async (importOriginal) => ({ ...(await importOriginal>()), - usePermissions: () => ({ checkField: () => writable.value }), + usePermissions: () => ({ isLoaded: true, checkField: () => writable.value }), })); const { ProfilePage } = await import('../ProfilePage'); diff --git a/apps/console/src/pages/system/__tests__/ProfilePage.sharedSelect-11865.test.tsx b/apps/console/src/pages/system/__tests__/ProfilePage.sharedSelect-11865.test.tsx index 085f89da72..e578fbcf5f 100644 --- a/apps/console/src/pages/system/__tests__/ProfilePage.sharedSelect-11865.test.tsx +++ b/apps/console/src/pages/system/__tests__/ProfilePage.sharedSelect-11865.test.tsx @@ -83,7 +83,7 @@ vi.mock('@object-ui/react', async (importOriginal) => ({ vi.mock('@object-ui/permissions', async (importOriginal) => ({ ...(await importOriginal>()), - usePermissions: () => ({ checkField: () => true }), + usePermissions: () => ({ isLoaded: true, checkField: () => true }), })); const { ProfilePage } = await import('../ProfilePage'); diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index fcdb8d4017..27a3ffbac0 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -460,7 +460,7 @@ as well as a simple form whose mobile `stepper` option routes it through the wizard, and the parent operation of a master-detail form. Every layout also strips what a form never writes, on a create as on an edit: server-owned, computed and read-only columns, keys the object does not declare, and fields the -caller's field-level security refuses — which every layout renders disabled. Every layout also disables every field of a managed object whose `userActions` do not open the form's mode, and of an object whose `create` or `update` the server's effective API operations deny. While that lock holds, the form shows one notice naming the object and the missing permission, and a `wizard` disables Next and its final submit button. A field whose sameness cannot be proven is sent: `5` and `'5'`, `null` and +caller's field-level security refuses — which every layout renders disabled. A field asks the question of the form's mode: a create form asks whether the caller may set it on a NEW record (the server's insert rule — a field the permission set does not mention falls back to the object's `allowCreate`), and an edit form whether they may change it on an existing one (`allowEdit`), so a role that may create but not edit can fill a create form. Every layout also disables every field of a managed object whose `userActions` do not open the form's mode, of an object whose `create` or `update` the server's effective API operations deny, and of an object whose create (create form) or update (edit form) grant the caller does not hold. While that lock holds, the form shows one notice naming the object and the missing permission, and a `wizard` disables Next and its final submit button. A field whose sameness cannot be proven is sent: `5` and `'5'`, `null` and `''`, and a lookup id and its expanded object all count as different. A save with nothing changed still sends the full payload, as it always has. The `ifMatch` concurrency guard is unchanged, and its **Overwrite** choice now resends only the diff --git a/packages/app-shell/src/views/ObjectDataPage.tsx b/packages/app-shell/src/views/ObjectDataPage.tsx index 5c70aa147d..2fd7955664 100644 --- a/packages/app-shell/src/views/ObjectDataPage.tsx +++ b/packages/app-shell/src/views/ObjectDataPage.tsx @@ -43,7 +43,7 @@ import { formatMetadataError } from '@object-ui/data-objectstack'; import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; import { usePermissions, useFieldPermissions } from '@object-ui/permissions'; import { useAuth, useWorkspaceAdminStatus } from '@object-ui/auth'; -import { resolveFilterPlaceholders } from '@object-ui/core'; +import { resolveAffordance, resolveFilterPlaceholders, type SchemaLike } from '@object-ui/core'; import { normalizeFilterOperator, ViewFilterRuleSchema } from '@objectstack/spec/ui'; import type { ViewFilterRule } from '@objectstack/spec/ui'; import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState.js'; @@ -73,7 +73,7 @@ import { PREVIEW_QUERY_VALUE, } from '../preview/PreviewModeContext.js'; import { useTenancyPosture } from '../hooks/useTenancyPosture.js'; -import { resolveEffectiveCrudAffordances, type RowCrudPredicates } from '../utils/crudAffordances.js'; +import type { RowCrudPredicates } from '../utils/crudAffordances.js'; /** Field types the auto-derived user-filter bar offers as dropdowns. */ const USER_FILTER_TYPES = new Set(['select', 'multiselect', 'radio', 'enum', 'boolean']); @@ -195,7 +195,8 @@ export function ObjectDataPage({ dataSource, objects }: any) { const { objectLabel, objectPluralLabel, fieldLabel } = useObjectLabel(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const { can, getObjectApiOperations } = usePermissions(); + const perms = usePermissions(); + const { can } = perms; const { canRead } = useFieldPermissions(objectName ?? ''); const { user, activeOrganization } = useAuth(); const { isAdmin } = useWorkspaceAdminStatus(); @@ -423,17 +424,19 @@ export function ObjectDataPage({ dataSource, objects }: any) { // objectstack#3391 effective-API-operation intersection was absent, so the toolbar could // offer a create the server would 405. // - // Resolved exactly as `ObjectView` does: the spec's bucket/`userActions` - // matrix (ADR-0103, delegated to `resolveCrudAffordances`), INTERSECTED with - // the server-resolved effective API operations for this object. `undefined` - // (unrestricted object / old backend) leaves the bucket affordances as-is. - const affordances = React.useMemo( + // Resolved exactly as `ObjectView` does, through the SAME `listNew` row of + // the affordance-to-grant map (`resolveAffordance` in `@object-ui/core`, + // objectui#12082): the spec's bucket/`userActions` matrix (ADR-0103, + // delegated to `resolveCrudAffordances`), INTERSECTED with the + // server-resolved effective API operations for this object, AND the caller's + // create grant. `undefined` operations (unrestricted object / old backend) + // leave the bucket affordances as-is. + const newVerdict = React.useMemo( () => - resolveEffectiveCrudAffordances( - objectDef as any, - objectDef ? getObjectApiOperations(objectDef.name) : undefined, - ), - [objectDef, getObjectApiOperations], + objectDef + ? resolveAffordance('listNew', { objectSchema: objectDef as SchemaLike, objectName: objectDef.name, perms }) + : { allowed: false }, + [objectDef, perms], ); /** @@ -460,10 +463,8 @@ export function ObjectDataPage({ dataSource, objects }: any) { * ONE RENDER POINT here, unlike `ObjectView`: this page has no phone FAB — * the whole PageHeader lives under `hidden sm:block`. */ - const objectCanCreate = !!objectDef && affordances.create && can(objectDef.name, 'create'); - const createPredicates: RowCrudPredicates | undefined = objectCanCreate - ? affordances.createPredicates - : undefined; + const objectCanCreate = newVerdict.allowed; + const createPredicates: RowCrudPredicates | undefined = newVerdict.predicates; /** `visibleWhen` — fails CLOSED, declared-ness by `?? true` rather than by * truthiness, so `visibleWhen: false` (the objectui#3492 shape) hides "New" * instead of reading as "ungated". The `true` default is a boolean, which @@ -543,8 +544,8 @@ export function ObjectDataPage({ dataSource, objects }: any) { <> {/* [#5164] `objectCanCreate && createVisible` — the bucket + object-level `userActions` + objectstack#3391 effective-operations - verdict (all folded into `affordances.create`) AND the - principal's grant, then the toolbar-scope `visibleWhen` layer + verdict AND the principal's grant (the map's `listNew` row, + objectui#12082), then the toolbar-scope `visibleWhen` layer on top of it. Greyed, not gone, is the `disabledWhen` case. */} {objectCanCreate && createVisible && (