When the Server Fails Closed, the Interface Must Tell the Truth
The server refused free uploads from scoped editors while the UI kept offering the button. Gating the picker fixed the affordance, not the security.
TL;DR
Scoped editors could see KotaPortal's media upload button but always hit a 403, since only admin-global accounts could upload free. The fix gates the button, file input, and handler behind the same server-side permission check, so the UI stops promising what the server refuses. Lesson: hiding UI is hygiene, not enforcement; the server remains the real gate.
A scoped editor opens the KotaPortal media picker, uploads a file without choosing an entity, and gets a 403 toast every time. The same upload succeeds from the admin-global account. The button had been visible all along, promising an action the server would always refuse. The contrast creates real confusion: the interface advertises a capability the account does not have.
The wrong guess: hide the button and the rule is enforced. Hiding an element is hygiene, not enforcement. The server had long been fail-closed, accepting free uploads only from admin-global [1]. The UI was the layer that lied, setting expectations the system would never meet.
Where Access Control Actually Lives
Access control is only effective in trusted server-side code, where an attacker cannot modify the check or its metadata [1]. Except for public resources, deny by default [1]. The interface gate mirrors that server rule; it never replaces the enforcement point.
Gating the Picker
The fix places the upload button, the file input, and the handler behind canManageModule(bidang, ""), mirroring the server rule and the existing canUploadFree pattern in the media view. The before-and-after is small but visible: before, a scoped editor picked a file and learned about the rule from a 403 toast after the request failed; now the button never renders, and the empty state text no longer recommends an action that is unavailable. Crafting the request by hand still ends at the same 403, because the server never stopped checking. Placing the predicate next to the rule it mirrors also helps maintenance: when the server rule changes, the interface mirror is a one-line diff away. The empty string as the module argument is an unusual input, the kind of case that quietly changes meaning during a refactor. The test in rbac.test.ts pins the empty-module semantics, so a silent behavior shift gets caught before production.
Conditional rendering is plain JavaScript: if statements, the && operator, the ternary operator, and return null when nothing should render [9]. Component state is the memory the screen renders from [10]. When permissions change, a state update rerenders and the forbidden element disappears automatically, and gating the handler saves one doomed round trip: the request is never sent.
The Empty State Is Part of the Gate
A 403 toast as the only answer to a forbidden action is an affordance bug: the design suggests an action that cannot be performed. The empty state text no longer suggests a button that will never appear for scoped editors. Hiding it conditionally gives honest feedback about what the role can actually do.
Library selection stays open, because the media list is read-open across bidang: the read path is not blocked by the write restriction. That split is deliberate and worth keeping deliberate. A picker that both lists and uploads under one permission tends to drift toward whichever rule is stricter, and editors lose the legitimate use: browsing existing media to reference them. Keeping two mental sets, one for what may be read and one for what may be written, keeps the gate as narrow as the rule it mirrors.
Gating the handler in addition to the button is not redundancy either. The button hides the affordance from ordinary use; the handler check covers the paths a determined user can still reach, such as resubmitting a form that was open while permissions changed, or calling the handler from another component that forgot the gate. One layer serves the human, the other serves the contract.
When a server rule exists but no interface surfaces it, users do not learn the rule; they learn that the product lies. The server rule did not change in this commit; only the interface did. Aligning the UI with the server rule removes the confusion permanently: the system no longer offers a choice that cannot be executed, and enforcement stays at the point that actually enforces.
Sources
[1] Broken_Access_Control/index.html — OWASP Top 10:2021 A01 Broken Access Control
[9] rendering — React Docs: Conditional Rendering
[10] a-components-memory — React Docs: State - A Component's Memory