fix(frontend/ux): hide admin-only sections instead of toasting "admin access required" (closes #365) by cristim · Pull Request #452 · LeanerCloud/cloud-commitments-cli · GitHub
Skip to content

fix(frontend/ux): hide admin-only sections instead of toasting "admin access required" (closes #365) - #452

Merged
cristim merged 7 commits into
feat/multicloud-web-frontendfrom
fix/issue-365-hide-admin-ui
May 14, 2026
Merged

cristim merged 7 commits into
feat/multicloud-web-frontendfrom
fix/issue-365-hide-admin-ui

Conversation

@cristim

@cristim cristim commented May 14, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #365. PR #364 widened read endpoints to AuthUser so non-admin sessions can browse the dashboard, but every page still rendered admin-only buttons that fired backend 403s on click and surfaced as red "admin access required" toasts. This PR moves the gate one step earlier: hide the buttons the session can't actually invoke.

A new frontend/src/permissions.ts module mirrors the backend's DefaultAdminPermissions / DefaultUserPermissions / DefaultReadOnlyPermissions (internal/auth/types.go:367-415) and exposes canAccess(action, resource). The role-defaults portion of the previously-private userList.ts:effectivePermissions is refactored to delegate to the shared helper so the admin-Users-page permission badge and the new global UI gates stay in lockstep.

What gets hidden, by role

Surface Verb Admin User Readonly
Sidebar Admin tab (#admin-tab-btn) admin:* shown hidden hidden
Plans → "New Plan" (#new-plan-btn) create:plans shown shown hidden
Plans card → Add Purchases / Edit / toggle update:plans shown shown hidden
Plans card → Delete delete:plans shown hidden hidden
Planned-purchases row → Run / Pause / Resume / Edit update:plans shown shown hidden
Planned-purchases row → Disable delete:plans shown hidden hidden
Opportunities → Purchase one-off (#bulk-purchase-btn) execute:purchases shown hidden hidden
Opportunities → Create Plan (#create-plan-btn) create:plans shown shown hidden
RI Exchange → convertible-RI Exchange button admin:* shown hidden hidden
RI Exchange → reshape-recs Exchange button admin:* shown hidden hidden
Settings → Save / Reset, form inputs admin:* shown hidden + disabled hidden + disabled
Admin → Users / Accounts sections (unchanged) admin:* shown hidden hidden
Plans card → History button always visible shown shown shown
Settings form (read-only view) always visible editable read-only read-only

.admin-only.visible flips from display: block to display: revert so each element falls back to its UA / cascade default when shown. Sections still render as block, buttons as inline-block, and the sidebar tab buttons inherit the inline-flex from .app-sidebar-nav .tab-btn. The previous display: block would have flattened the sidebar's flex layout on the newly-admin-gated #admin-tab-btn.

Defense in depth

Frontend gates are pure UX. Backend requirePermission checks remain authoritative (PR #84, PR #364). A wrong-positive in this client mirror surfaces as a 403 toast on click (the existing failure mode); a wrong-negative just hides a button. The new permissions.test.ts enumerates every entry of every role default so a future drift between the client mirror and internal/auth/types.go fails CI rather than at runtime.

Side effect: complete badge

The previously-private effectivePermissions mirror in userList.ts was missing the cancel-own:purchases / retry-own:purchases / approve-own:purchases verbs that DefaultUserPermissions() actually grants. The admin Users page badge for user-role accounts was under-reporting their permissions. The refactor catches that up. Unknown roles previously fell through to the user defaults; they now return the empty permission set (matches the backend's deny-by-default).

Test plan

  • npx tsc --noEmit clean.
  • npx jest — 1688 tests pass (137 new across permissions, plans-permissions, recommendations-permissions, riexchange-permissions, settings-permissions, plus the sidebar-button assertion in html.test.ts).
  • npx webpack --mode production builds the frontend bundle without errors.
  • After merge: signed-in readonly and user sessions browse the full app and see zero "admin access required" toasts during normal navigation.

Summary by CodeRabbit

  • New Features

    • Role-based access control applied across the UI: plan management, recommendation actions, RI exchange, and settings now show/hide controls based on permissions; non-admins see a read-only settings form.
    • Centralized, generated permission definitions with enforcement and build-time regeneration checks.
  • Style

    • Admin sidebar tab visibility updated to be handled via an admin-only class.
  • Tests

    • Added comprehensive permission-focused UI and unit tests covering multiple roles.

Review Change Stack


Codegen follow-up (added 2026-05-13)

The original change hand-wrote frontend/src/permissions.ts as a mirror of the Go defaults. permissions.test.ts enumerated the expected entries but compared them against the mirror itself, so a drift in the Go defaults would not have been detected by the existing tests.

This follow-up replaces the hand-written role-default sets with build-time codegen:

  • Generator: cmd/gen-permissions/main.go imports internal/auth, reads DefaultAdminPermissions() / DefaultUserPermissions() / DefaultReadOnlyPermissions(), sorts entries by (action, resource) for a stable diff, and writes frontend/src/permissions.generated.ts.

  • Wrapper: frontend/src/permissions.ts keeps the hand-written Action / Resource closed-union types and the canAccess / isAdmin / getRolePermissions helpers, importing the three permission sets from the generated file. Callers and tests are unchanged.

  • Drift detection: a new permissions-codegen hook in .pre-commit-config.yaml re-runs the generator and git diff --exit-code on the generated file. The hook triggers on changes to internal/auth/types.go, the generator, or the generated file itself. CI catches the same drift via the existing pre-commit GitHub Actions workflow that runs pre-commit run --all-files.

  • How to regenerate after editing the Go defaults:

    go run ./cmd/gen-permissions
    git add frontend/src/permissions.generated.ts

cristim added 6 commits May 14, 2026 03:14
Introduce `frontend/src/permissions.ts` with `canAccess(action, resource)`
+ `getRolePermissions(role)` mirroring the backend's
`DefaultAdminPermissions` / `DefaultUserPermissions` /
`DefaultReadOnlyPermissions` (`internal/auth/types.go:367-415`).

Drive `auth.ts:isAdmin` and `users/userList.ts:effectivePermissions`
from the new shared module so the UI badge in the admin Users page
and the global UI gates that follow in subsequent commits stay in
lockstep.

Side-effect: the badge in the Users page now also shows the
`cancel-own:purchases` / `retry-own:purchases` / `approve-own:purchases`
entries for `user`-role accounts that were previously missing from the
local mirror in `effectivePermissions`. The backend already grants
these per PR #364; this catches the badge up.

Unknown roles previously fell through to `user` defaults in
`effectivePermissions`. Now they return the empty permission set,
matching the backend's deny-by-default behaviour. No production role
falls in this bucket today; the change closes a latent fail-open.

Tests enumerate every entry in each role default so a future drift
between this mirror and the backend fails CI rather than at runtime.
Mark `#admin-tab-btn` with `admin-only` so the existing
`auth.ts:updateUserUI` toggle hides it for non-admin sessions. Before
this commit a readonly or user-role login saw the Admin sidebar entry,
clicked it, and got a stack of red 403 toasts on every nested API
call because the page itself stayed admin-only.

Switch `.admin-only.visible` from `display: block` to `display: revert`
so each element falls back to its UA / cascade default. Sections still
render as block, buttons as inline-block, and the sidebar tab buttons
inherit the inline-flex from `.app-sidebar-nav .tab-btn`. With the old
`display: block` rule the sidebar Admin button would have flattened
the sidebar's flex layout for admins.
…e verb (#365)

Gate three classes of buttons on the Plans page by the same
permission a click on each one would require, so a non-admin
(readonly especially) never sees a button whose only outcome is a 403.

* `#new-plan-btn` (top-level "New Plan"): hidden unless
  `create:plans` (admin + user keep it; readonly loses it).
* Plan-card Add Purchases / Edit / toggle-plan: gated by
  `update:plans` (admin + user keep them; readonly loses all three).
* Plan-card Delete: gated by `delete:plans` (admin only).
* Plan-card View History stays for every role: read-only inspection.
* Planned-purchase row Run / Pause / Resume / Edit: gated by
  `update:plans`.
* Planned-purchase row Disable: gated by `delete:plans`.

Permission lookups are cached once per render rather than re-evaluated
per card so a 100-plan list doesn't bounce through the helper 600
times.

Backend `requirePermission` checks stay untouched. The frontend gates
are pure UX defense in depth.
…sessions without the verb (#365)

The Opportunities-tab bottom-action box renders two CTAs:
`#bulk-purchase-btn` ("Purchase" one-off) and `#create-plan-btn`
("Create Plan"). Both stayed visible for every signed-in session,
and a readonly user clicking either got a 403 toast.

Gate each CTA by the underlying verb:
* Purchase: `execute:purchases` (admin only by default).
* Create Plan: `create:plans` (admin + user keep it; readonly loses it).

The action box itself stays visible for every role so readonly users
still get the selection summary and the capacity input as read-only
browsing aids. Only the mutating buttons disappear.
…ns (#365)

The convertible-RI table and the reshape-recommendations table both
render per-row "Exchange" buttons that hit admin-only backend
endpoints. A readonly or user-role session clicking either currently
gets a 403 "admin access required" toast.

Gate both buttons by `admin:*` (the verb the backend handler
requires). For non-admin sessions also drop the Actions column
header so the table doesn't render with a dangling empty column.
…ions (#365)

The /admin/general and /admin/purchasing sub-tabs are reachable for
every signed-in role (only /admin/accounts and /admin/users get the
navigation.ts non-admin redirect). Previously a user-role or readonly
session could open Settings, edit fields, and only learn the values
weren't saved when Save returned 403.

Render the form read-only for non-admin sessions:
* Disable every input / select / textarea / button under
  `#global-settings-form`.
* Hide `#save-settings-btn` and `#reset-settings-btn` via the HTML
  hidden attribute.

The form stays visible so non-admin sessions can still inspect the
configured providers, defaults, and grace windows as a reference.
Backend remains authoritative and 403s any attempted write.
@coderabbitai

coderabbitai Bot commented May 14, 2026 •

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ffac8901-1ca9-435d-905b-1d6b66d27275

📥 Commits

Reviewing files that changed from the base of the PR and between 85e2ace and 73467ef.

📒 Files selected for processing (4)
  • .pre-commit-config.yaml
  • cmd/gen-permissions/main.go
  • frontend/src/permissions.generated.ts
  • frontend/src/permissions.ts
✅ Files skipped from review due to trivial changes (1)
  • frontend/src/permissions.generated.ts

📝 Walkthrough

Walkthrough

Centralizes frontend permission checks: adds a generated role-permissions source, a UX-only permissions.ts helper with unit tests, delegates auth/userList to it, and gates UI controls across Plans, Recommendations, RI Exchange, Settings, plus admin tab HTML/CSS updates and tests.

Changes

Frontend Permission Gating

Layer / File(s) Summary
Permissions helper, generated defaults, and unit tests
frontend/src/permissions.ts, frontend/src/permissions.generated.ts, cmd/gen-permissions/*, .pre-commit-config.yaml, frontend/src/__tests__/permissions.test.ts
Adds Action/Resource types, getRolePermissions, canAccess, and isAdmin() using generated backend defaults; includes generator and pre-commit hook to keep generated file current; comprehensive Jest tests cover admin/user/readonly/null and unknown roles.
Auth delegation and userList refactor
frontend/src/auth.ts, frontend/src/users/userList.ts
auth.ts now delegates isAdmin() to permissions.isAdmin(); userList.ts uses getRolePermissions() to seed effectivePermissions instead of inline defaults.
Plans page UI gating and tests
frontend/src/plans.ts, frontend/src/__tests__/plans-permissions.test.ts, frontend/src/__tests__/plans.test.ts
Plans UI imports canAccess to hide the top-level New Plan button and per-card/per-row mutating actions (Add/Edit/Delete/Enable/Disable) based on permissions; planned-purchase rows compute per-row permission booleans. Tests validate admin/user/readonly/null behaviors.
Recommendations bottom action-box gating and tests
frontend/src/recommendations.ts, frontend/src/__tests__/recommendations-permissions.test.ts, frontend/src/__tests__/recommendations.test.ts
Bottom action box hides “Purchase” and/or “Create Plan” buttons based on execute:purchases and create:plans; non-mutating controls remain visible. Tests cover all roles and logged-out state.
RI Exchange gating and tests
frontend/src/riexchange.ts, frontend/src/__tests__/riexchange-permissions.test.ts
Adds admin-only gating for the “Actions” column and per-row Exchange buttons in both Convertible RIs and Reshape tables; tests assert admin vs non-admin rendering.
Settings page read-only mode and tests
frontend/src/settings.ts, frontend/src/__tests__/settings-permissions.test.ts
loadGlobalSettings() applies a read-only wrapper that disables form controls and hides Save/Reset for non-admin sessions; tests validate control enablement/visibility across roles.
Admin tab HTML/CSS and regression test
frontend/src/index.html, frontend/src/styles/base.css, frontend/src/__tests__/html.test.ts
Admin sidebar button marked admin-only; .admin-only.visible uses display: revert; regression test verifies the button and class.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • LeanerCloud/CUDly#275: Modifies Recommendations bottom action box logic for selection-based enable/disable alongside permission-based hiding.

Suggested labels

severity/medium, urgency/this-quarter

🐰 I hopped through code with care and speed,
Permissions tucked so users only see their need,
Admin buttons hidden, tests snug in a row,
Generated defaults keep behavior in tow,
A small rabbit cheers — neat UX wherever you go!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 58.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title accurately summarizes the main change: hiding admin-only UI sections instead of showing error toasts, addressing the core objective of issue #365.
Linked Issues check ✅ Passed All primary coding objectives from issue #365 are met: canAccess helper extracted with tests, permissions mirrored from backend, userList.ts refactored, and UI elements hidden per role (Admin nav, Plans create/edit/delete, Purchases actions, RI Exchange, Settings write actions).
Out of Scope Changes check ✅ Passed All changes align with issue #365 scope: frontend permission layering, UI visibility gating, role-default extraction, and pre-commit codegen for drift detection. No backend RBAC or per-account scoping changes introduced.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-365-hide-admin-ui

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added priority/p2 Backlog-worthy severity/low Minor harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/m Days type/feat New capability triaged Item has been triaged labels May 14, 2026
@cristim

cristim commented May 14, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

The hand-written `frontend/src/permissions.ts` mirrored the backend's
DefaultAdminPermissions / DefaultUserPermissions / DefaultReadOnlyPermissions
in `internal/auth/types.go`, but `permissions.test.ts` enumerated entries
against the TS mirror itself, so a drift in the Go defaults would not be
caught by the existing tests.

This change splits the data portion (the three default permission sets)
into a generated file `permissions.generated.ts` produced by
`go run ./cmd/gen-permissions`, which imports `internal/auth` and sorts
entries by (action, resource) for a stable diff. The wrapper
`permissions.ts` keeps the hand-written closed-union Action / Resource
types and the canAccess / isAdmin / getRolePermissions helpers and
imports the sets from the generated file. Existing callers in plans.ts,
settings.ts, recommendations.ts, riexchange.ts, auth.ts and
users/userList.ts plus the permissions.test.ts suite continue to work
unchanged.

A new `permissions-codegen` pre-commit hook re-runs the generator and
`git diff --exit-code` against the committed file so a stale copy
fails locally on commit, and CI catches the same drift via the existing
pre-commit GitHub Actions workflow that runs `pre-commit run --all-files`.
@cristim

cristim commented May 14, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

@cristim
cristim merged commit d448d72 into feat/multicloud-web-frontend May 14, 2026
5 checks passed
@cristim
cristim deleted the fix/issue-365-hide-admin-ui branch June 3, 2026 21:54
cristim added a commit that referenced this pull request Sep 27, 2026
… access required" (closes #365) (#452)

* refactor(frontend): extract permissions helper from userList (#365)

Introduce `frontend/src/permissions.ts` with `canAccess(action, resource)`
+ `getRolePermissions(role)` mirroring the backend's
`DefaultAdminPermissions` / `DefaultUserPermissions` /
`DefaultReadOnlyPermissions` (`internal/auth/types.go:367-415`).

Drive `auth.ts:isAdmin` and `users/userList.ts:effectivePermissions`
from the new shared module so the UI badge in the admin Users page
and the global UI gates that follow in subsequent commits stay in
lockstep.

Side-effect: the badge in the Users page now also shows the
`cancel-own:purchases` / `retry-own:purchases` / `approve-own:purchases`
entries for `user`-role accounts that were previously missing from the
local mirror in `effectivePermissions`. The backend already grants
these per PR #364; this catches the badge up.

Unknown roles previously fell through to `user` defaults in
`effectivePermissions`. Now they return the empty permission set,
matching the backend's deny-by-default behaviour. No production role
falls in this bucket today; the change closes a latent fail-open.

Tests enumerate every entry in each role default so a future drift
between this mirror and the backend fails CI rather than at runtime.

* feat(frontend): hide the sidebar Admin tab for non-admins (#365)

Mark `#admin-tab-btn` with `admin-only` so the existing
`auth.ts:updateUserUI` toggle hides it for non-admin sessions. Before
this commit a readonly or user-role login saw the Admin sidebar entry,
clicked it, and got a stack of red 403 toasts on every nested API
call because the page itself stayed admin-only.

Switch `.admin-only.visible` from `display: block` to `display: revert`
so each element falls back to its UA / cascade default. Sections still
render as block, buttons as inline-block, and the sidebar tab buttons
inherit the inline-flex from `.app-sidebar-nav .tab-btn`. With the old
`display: block` rule the sidebar Admin button would have flattened
the sidebar's flex layout for admins.

* feat(frontend/plans): hide plan write actions for sessions without the verb (#365)

Gate three classes of buttons on the Plans page by the same
permission a click on each one would require, so a non-admin
(readonly especially) never sees a button whose only outcome is a 403.

* `#new-plan-btn` (top-level "New Plan"): hidden unless
  `create:plans` (admin + user keep it; readonly loses it).
* Plan-card Add Purchases / Edit / toggle-plan: gated by
  `update:plans` (admin + user keep them; readonly loses all three).
* Plan-card Delete: gated by `delete:plans` (admin only).
* Plan-card View History stays for every role: read-only inspection.
* Planned-purchase row Run / Pause / Resume / Edit: gated by
  `update:plans`.
* Planned-purchase row Disable: gated by `delete:plans`.

Permission lookups are cached once per render rather than re-evaluated
per card so a 100-plan list doesn't bounce through the helper 600
times.

Backend `requirePermission` checks stay untouched. The frontend gates
are pure UX defense in depth.

* feat(frontend/recommendations): hide bulk Purchase + Create Plan for sessions without the verb (#365)

The Opportunities-tab bottom-action box renders two CTAs:
`#bulk-purchase-btn` ("Purchase" one-off) and `#create-plan-btn`
("Create Plan"). Both stayed visible for every signed-in session,
and a readonly user clicking either got a 403 toast.

Gate each CTA by the underlying verb:
* Purchase: `execute:purchases` (admin only by default).
* Create Plan: `create:plans` (admin + user keep it; readonly loses it).

The action box itself stays visible for every role so readonly users
still get the selection summary and the capacity input as read-only
browsing aids. Only the mutating buttons disappear.

* feat(frontend/riexchange): hide Exchange buttons for non-admin sessions (#365)

The convertible-RI table and the reshape-recommendations table both
render per-row "Exchange" buttons that hit admin-only backend
endpoints. A readonly or user-role session clicking either currently
gets a 403 "admin access required" toast.

Gate both buttons by `admin:*` (the verb the backend handler
requires). For non-admin sessions also drop the Actions column
header so the table doesn't render with a dangling empty column.

* feat(frontend/settings): render Settings read-only for non-admin sessions (#365)

The /admin/general and /admin/purchasing sub-tabs are reachable for
every signed-in role (only /admin/accounts and /admin/users get the
navigation.ts non-admin redirect). Previously a user-role or readonly
session could open Settings, edit fields, and only learn the values
weren't saved when Save returned 403.

Render the form read-only for non-admin sessions:
* Disable every input / select / textarea / button under
  `#global-settings-form`.
* Hide `#save-settings-btn` and `#reset-settings-btn` via the HTML
  hidden attribute.

The form stays visible so non-admin sessions can still inspect the
configured providers, defaults, and grace windows as a reference.
Backend remains authoritative and 403s any attempted write.

* feat(frontend/permissions): generate role-default sets from Go source

The hand-written `frontend/src/permissions.ts` mirrored the backend's
DefaultAdminPermissions / DefaultUserPermissions / DefaultReadOnlyPermissions
in `internal/auth/types.go`, but `permissions.test.ts` enumerated entries
against the TS mirror itself, so a drift in the Go defaults would not be
caught by the existing tests.

This change splits the data portion (the three default permission sets)
into a generated file `permissions.generated.ts` produced by
`go run ./cmd/gen-permissions`, which imports `internal/auth` and sorts
entries by (action, resource) for a stable diff. The wrapper
`permissions.ts` keeps the hand-written closed-union Action / Resource
types and the canAccess / isAdmin / getRolePermissions helpers and
imports the sets from the generated file. Existing callers in plans.ts,
settings.ts, recommendations.ts, riexchange.ts, auth.ts and
users/userList.ts plus the permissions.test.ts suite continue to work
unchanged.

A new `permissions-codegen` pre-commit hook re-runs the generator and
`git diff --exit-code` against the committed file so a stale copy
fails locally on commit, and CI catches the same drift via the existing
pre-commit GitHub Actions workflow that runs `pre-commit run --all-files`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/low Minor harm triaged Item has been triaged type/feat New capability urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant