Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/11212-permission-gates-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
---
'@object-ui/components': minor
'@object-ui/plugin-detail': minor
'@object-ui/react': minor
---

fix: an action gated on `current_user.can(object, verb)` stays hidden until the permissions payload has loaded on every action `visible` surface, a `page:header` action gated through `disabled` stays disabled, and `record:quick_actions` can answer `current_user` at all

An action whose `visible` asks `current_user.can(...)` has no answer until the signed-in
user's permissions have loaded. On `action:group` (both display modes, and the group's own
`visible`), `action:icon` and a related list's toolbar, that unanswerable gate used to
SHOW the action, so a button meant for permission holders flashed up for everyone while
the page loaded, and stayed up where the permissions never load (the `/forms/:name` route,
a standalone embed). These renderers now treat a `visible` predicate that cannot be
evaluated the way `action:button` and `action:menu` already did: the action is hidden and
the console names it once. This is the rule for the key, not a special case for `can`: a
`visible` that faults for any other reason (a misspelled root such as `nope.x == 1`) is
hidden too, where it used to be shown.

On `page:header`, a `disabled` predicate that cannot be evaluated now renders the button
DISABLED instead of enabled, so `disabled: "!current_user.can('account', 'delete')"`
shows a greyed-out button until the answer arrives. This also applies to a `disabled`
predicate that faults for another reason. A header action's `visible` and `hidden`
predicates keep their fail directions.

`record:quick_actions` filters its actions against the action runner's context, which
held the host's `user` but never `current_user`, so `current_user.can(...)` and every
other `current_user.*` gate there hid the action for everyone, the users who hold the
permission included. `useActionEngine` (`@object-ui/react`) now binds the signed-in user
from the surrounding `ExpressionProvider` as `current_user`, so a quick action is shown
to users who hold the permission and hidden from those who don't, and while the
permissions are loading.
10 changes: 7 additions & 3 deletions content/docs/layout/page-header.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,13 @@ declaration gates both places.
not a `false`.
- Call it on the signed-in user. `user.can(…)`, `ctx.user.can(…)` and `os.user.can(…)`
are the same call; a bare `can(…)`, or `record.can(…)`, is a fault.
- Until the permissions payload has loaded there is no answer: the call faults, and a
header action whose `visible` faults is not rendered (see above). Gate on `visible`,
not `disabled` — a `disabled` predicate that faults leaves the action enabled.
- Until the permissions payload has loaded there is no answer: the call faults, and the
gate stays closed. A header action whose `visible` faults is not rendered (see above),
and one whose `disabled` faults is rendered disabled, so
`disabled: "!current_user.can('account', 'delete')"` shows a greyed-out button until the
answer arrives. The action renderers that draw actions elsewhere (`action:button`,
`action:group`, `action:icon`, a related list's toolbar, `record:quick_actions`) hide a
faulting `visible` the same way.
- `requiredPermissions` is a different gate: it names system capabilities, which the
platform action route enforces as well as the UI. `current_user.can` asks about
object permissions and does nothing on the server.
Expand Down
Loading
Loading