Skip to content

Catalog: say why a disabled Admin Users switch is disabled (5217 stack 5/9) - #5263

Open
nl0 wants to merge 6 commits into
stack/5217-4-search-ordering-visibilityfrom
stack/5217-5-admin-disabled-switch-reason
Open

Catalog: say why a disabled Admin Users switch is disabled (5217 stack 5/9)#5263
nl0 wants to merge 6 commits into
stack/5217-4-search-ordering-visibilityfrom
stack/5217-5-admin-disabled-switch-reason

Conversation

@nl0

@nl0 nl0 commented Aug 31, 2026

Copy link
Copy Markdown
Member

Description

In Admin → Users, a disabled Enabled or Admin switch said nothing about
why. Two different causes — "that row is you" and "that is a service user managed
by the stack" — rendered as the same mute, dead control in the same column, and a
dead control with no stated cause is indistinguishable from a rendering bug.

Each switch column now resolves a reason and derives disabled from whether
there is one, so the guard and its explanation cannot drift apart. The reason
reaches keyboard and screen-reader admins as well as mouse users: a disabled
switch fires no events and is out of the tab order, so the tooltip hangs on a
focusable, named wrapper rather than on the switch.

Review findings addressed

  • f29 — the wrapper that carries the reason is a <span tabIndex={0}):
    focusable, and not a ButtonBase. constants/style implements the Focus Ring
    Rule (catalog/DESIGN.md §2, bound onto UI changes by catalog/CLAUDE.md) for
    MuiButtonBase only, so the new element took keyboard focus with nothing drawn
    on it — an invisible-focus defect on an element added by an accessibility fix.
    It now carries the same 2px ring, at the same offset, from the theme palette.
    The review called this the strongest citation in its set; it was parked on rank,
    not on merit.
  • f38 — this unit introduced oxlint-disable-next-line while 111 files in
    app/ use eslint-disable-next-line and two use the oxlint form. oxlint honors
    the eslint comments, so the majority form ships here. Worth a ruling: if the
    intent is to migrate the other 111 rather than keep them, say so and this flips.

Deliberate assumptions and recorded observations

  • f32 — stated as an assumption, because this repository cannot settle it.
    The Admin switch now refuses every isService user, and puts isService
    ahead of the SSO flag when choosing which reason to show. The GraphQL contract
    this consumes does not declare that coupling; the registry that computes
    isService and isAdminAssignmentDisabled is outside this repository, so
    nothing in the checkout proves the two always travel together. The guard is
    written not to depend on it — refusing a service user is correct on its own
    terms, and "managed by the stack" is the truer reason where both hold — but a
    reviewer with registry access should confirm there is no service user for whom
    admin assignment is legitimately allowed. The review graded this PLAUSIBLE
    rather than CONFIRMED for exactly this reason.
  • f8 — "what may an admin do to this user" is still re-derived at each render
    site rather than resolved once beside the flags. The
    whyEnabledDisabled / whyAdminDisabled split answers part of it. Out of scope
    here.
  • f35 — checked and refuted: the reason is not attached where ARIA forbids an
    accessible name.

For reference, the hardening review this stack comes from compared master
(6167dd82) against the
26.7.4 pin (eda3016f) —
linked rather than named, so the comparison is checkable.

Verification

cd catalog && npx vitest run app/containers/Admin/UsersAndRoles

12 tests. The focus-ring assertion reads the generated stylesheet for a
:focus-visible outline rule on the wrapper's own class, and was checked against
a version with the class removed.

Position in the stack

PR 5 of 9, based on
stack/5217-4-search-ordering-visibility.

Part of the split of #5217 asked for in
f27. PR 6
edits the same file and stacks directly above; the two sets of hunks are disjoint
and share no symbol, so either order is correct. This one is first because its
fold-in is a present-tense accessibility defect on an element the diff adds.

TODO

  • Unit tests
  • Security: Confirm that this change meets security best practices and does not violate the security model
  • Open: Confirm that this change doesn't break the Open variant
  • Changelog entry

🤖 Generated with Claude Code

Greptile Summary

This PR explains why Enabled and Admin switches are disabled for self-managed and stack-managed users.

  • Adds a focusable, accessibly named tooltip carrier around disabled switches.
  • Derives switch disabled state from the corresponding explanatory reason.
  • Adds focused unit coverage for mouse, keyboard, focus-ring, and column-level behavior.
  • Documents the user-facing fix in the Catalog changelog.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking issue identified.

The existing self and service-user restrictions remain enforced, while disabled controls gain mouse and keyboard-accessible explanations with focused test coverage.

Important Files Changed

Filename Overview
catalog/app/containers/Admin/UsersAndRoles/Users.tsx Adds reason resolvers and an accessible tooltip wrapper while preserving the existing switch restrictions.
catalog/app/containers/Admin/UsersAndRoles/Users.spec.tsx Covers disabled reasons, keyboard focus, focus-ring styling, enabled hints, and ordinary editable users.
catalog/CHANGELOG.md Adds an accurate entry describing the disabled-switch explanation improvement.

Reviews (1): Last reviewed commit: "docs(changelog): entry for the disabled-..." | Re-trigger Greptile

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.14286% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 35.70%. Comparing base (ec3d8f9) to head (a633a1c).

Files with missing lines Patch % Lines
...talog/app/containers/Admin/UsersAndRoles/Users.tsx 82.14% 4 Missing and 1 partial ⚠️
Additional details and impacted files
@@                             Coverage Diff                             @@
##           stack/5217-4-search-ordering-visibility    #5263      +/-   ##
===========================================================================
+ Coverage                                    35.16%   35.70%   +0.53%     
===========================================================================
  Files                                          741      741              
  Lines                                        23808    23823      +15     
  Branches                                      6431     6433       +2     
===========================================================================
+ Hits                                          8373     8505     +132     
+ Misses                                       13691    13577     -114     
+ Partials                                      1744     1741       -3     
Flag Coverage Δ
catalog 35.70% <82.14%> (+0.53%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@nl0
nl0 marked this pull request as ready for review August 31, 2026 14:01
@nl0
nl0 force-pushed the stack/5217-5-admin-disabled-switch-reason branch from 733be45 to a36a203 Compare August 31, 2026 14:32
@nl0
nl0 force-pushed the stack/5217-5-admin-disabled-switch-reason branch 2 times, most recently from 70469c2 to 9142571 Compare August 31, 2026 16:10
@nl0
nl0 force-pushed the stack/5217-5-admin-disabled-switch-reason branch 2 times, most recently from c4ece23 to b2b968c Compare August 31, 2026 19:32
@nl0

nl0 commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Stack review pass — local code review, findings and dispositions

Reviewed this layer only, diffed against its stack base
(stack/5217-4-search-ordering-visibility), not against master.

Greptile: already reviewed this content. It delivered its pass as a summary
block appended to the PR description rather than as a review or inline comments —
there are no review threads on this PR to reply to. Its verdict was 5/5 with no
concrete blocking or independently actionable finding. It reviewed a pre-rebase
commit, but the three files in this layer are byte-identical between that commit
and the current head (the rebase moved only lower layers), so the pass still
applies.

Local review: five findings, three accepted, one accepted as a changelog
correction, one declined.

Accepted

1 — the reason was not reliably exposed to assistive tech. The carrier was a
bare <span tabIndex={0}>, which maps to role generic; ARIA prohibits
aria-label there and name computation for such nodes is not guaranteed. The
element added to carry the reason could therefore stay mouse-only — the exact
failure it exists to prevent. It now carries role="group", which permits the
label and keeps the switch inside it exposed. The spec asserts the role.

2 — the reason was announced twice. MUI seeds its own title on the tooltip
child while the popper is closed, so the span carried the reason as its
accessible name (aria-label) and as its description (title). Confirmed by
rendering the component and dumping the element's attributes. title is now
suppressed; the popper still supplies the description once open.

3 — the changelog entry, as a correction. whyAdminDisabled adds
user.isService as a new guard — the Admin switch was previously disabled only
on isSelf || isAdminAssignmentDisabled. That widening is deliberate and argued
in the description, but the entry advertised only the explanation and never
mentioned the newly-disabled control. The entry also quoted copy that is not in
the code ("you can't deactivate yourself" for "You cannot deactivate your own
account") and named two of the three reasons, omitting the SSO one. Rewritten on
all three counts. No behaviour changed.

5 — the keyboard test did not exercise the keyboard path. It dispatched a
synthetic focus event, which moves no focus. It passed only because
matches(':focus-visible') throws under this jsdom, so MUI falls back to a
module-global keyboard heuristic that still held its initial value — meaning any
test added earlier in the file that fires a pointer event would have broken this
one from a distance. It now takes real focus, primed with a keydown, and asserts
document.activeElement. Verified load-bearing: flipping that global with a
pointerdown fails the test.

Declined

4 — collapsing disabled and disabledReason into one prop. The drift is
real in principle: a caller passing a reason without disabled would get an
interactive switch and the reason silently dropped. But EditableSwitch is
module-local, both call sites derive disabled from the reason, and there is no
live path to the mismatch — so this is an API change to a shared component
mid-stack for a defect with no reachable instance. What was actually wrong was
the comment claiming the invariant is structural when it is convention; that
claim was corrected instead.

Also in this pass

Swept this layer's comments back to the constraints they carry. The unit had
shipped five multi-line blocks narrating why the change was made, into a file
whose entire existing comment budget is four one-line notes. Kept only what the
code cannot show — why the wrapper exists, why the focus ring is restated off
MuiButtonBase, why isService is tested before the SSO flag — and dropped the
rest, in the source and in the spec.

Checked for a deletion in this layer that none of its commits explains: there is
none. The Admin files are untouched by the layers below, and this layer's diff
against master is identical to its diff against its stack base.

Verification

cd catalog
npx vitest run app/containers/Admin/UsersAndRoles   # 12 passed
npx tsc --noEmit                                    # clean
npx oxlint app/containers/Admin/UsersAndRoles/Users.tsx app/containers/Admin/UsersAndRoles/Users.spec.tsx
npx oxfmt --check <same two files>                  # clean

The jsx-a11y/no-noninteractive-tabindex disable was checked to be load-bearing:
removing it fails the lint.

The four PRs above this one have been rebased onto the updated chain in order and
each still shows only its own layer.

nl0 and others added 6 commits September 1, 2026 10:17
A disabled switch with no stated cause is indistinguishable from a rendering
bug, and two different causes — it is you, or it is a service user managed by
the stack — rendered as the same mute, dead control in the same column.

Each switch column now resolves a reason and derives `disabled` from whether
there is one, so the guard and its explanation cannot drift apart. The reason
reaches keyboard and screen-reader users too: a disabled switch fires no events
and is out of the tab order, so the tooltip hangs on a focusable, named wrapper
rather than on the switch itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
addresses review findings f29, f38

The reason carrier this unit adds is a `<span tabIndex={0}>`: focusable, and not
a `ButtonBase`. `constants/style` implements the Focus Ring Rule
(catalog/DESIGN.md §2, bound onto UI changes by catalog/CLAUDE.md) for
`MuiButtonBase` only, so the new element took keyboard focus with nothing drawn
on it — an invisible-focus defect on an element added by an accessibility fix.
It now carries the same 2px ring in the ground's counter-color, at the same
offset.

Also settles the suppression prefix the same unit introduced: this file used
`oxlint-disable-next-line` while 111 others in `app/` use
`eslint-disable-next-line` and two use the oxlint form. oxlint honors the eslint
comments — `npm run lint:oxlint` passes with this change — so the majority form
is what ships here. Worth a maintainer ruling if the intent is to migrate the
other 111 rather than to keep them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
addresses review findings f44, f27

The "explains why" half of the Admin Users entry, citing the PR that ships it.
The read-only half lands with its own unit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reason carrier was a bare `<span tabIndex={0}>`, which maps to role
`generic`; ARIA forbids `aria-label` there and name computation for such
nodes is not guaranteed, so the reason could stay mouse-only -- the very
failure the wrapper exists to prevent. It carries `role="group"` now, which
both permits the label and keeps the switch inside it exposed.

MUI seeds its own `title` on the child while the popper is closed, so the
span carried the reason as its name and again as its description. Suppress
it; the popper still supplies the description once open.

The keyboard test dispatched a synthetic focus event, which moves no focus
and opened the tooltip only because `matches(':focus-visible')` throws under
this jsdom and MUI falls back to a module-global keyboard heuristic. It now
takes real focus, primed with a keydown, and asserts `document.activeElement`
-- checked by flipping that global with a pointerdown, which fails the test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This unit shipped five multi-line blocks narrating why the change was made,
into a file whose whole existing comment budget is four one-line notes. Keep
only what the code cannot show -- why the wrapper exists, why the focus ring
is restated off `MuiButtonBase`, why `isService` is tested before the SSO
flag -- and drop the rest.

The resolver comment also over-claimed: `disabled` is not structurally
derived from the reason, both remain independent props. Say what is true --
each call site derives it -- rather than asserting an invariant the type
does not enforce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The entry quoted copy that is not in the code ("you can't deactivate
yourself" for "You cannot deactivate your own account"), named two of the
three reasons the change adds -- omitting the SSO one entirely -- and said
nothing about the Admin switch now refusing service users, which is a
behaviour change and not only an explanatory one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@nl0
nl0 force-pushed the stack/5217-5-admin-disabled-switch-reason branch from 581fd40 to a633a1c Compare September 1, 2026 08:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant