Repository navigation
fix(frontend/ux): hide admin-only sections instead of toasting "admin access required" (closes #365) - #452
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
✅ Files skipped from review due to trivial changes (1)
📝 WalkthroughWalkthroughCentralizes frontend permission checks: adds a generated role-permissions source, a UX-only ChangesFrontend Permission Gating
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested labels
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai full review |
✅ Actions performedFull 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`.
|
@coderabbitai review |
… 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`.

Summary
Closes #365. PR #364 widened read endpoints to
AuthUserso 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.tsmodule mirrors the backend'sDefaultAdminPermissions/DefaultUserPermissions/DefaultReadOnlyPermissions(internal/auth/types.go:367-415) and exposescanAccess(action, resource). The role-defaults portion of the previously-privateuserList.ts:effectivePermissionsis 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
#admin-tab-btn)admin:*#new-plan-btn)create:plansupdate:plansdelete:plansupdate:plansdelete:plans#bulk-purchase-btn)execute:purchases#create-plan-btn)create:plansadmin:*admin:*admin:*admin:*.admin-only.visibleflips fromdisplay: blocktodisplay: revertso 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 previousdisplay: blockwould have flattened the sidebar's flex layout on the newly-admin-gated#admin-tab-btn.Defense in depth
Frontend gates are pure UX. Backend
requirePermissionchecks 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 newpermissions.test.tsenumerates every entry of every role default so a future drift between the client mirror andinternal/auth/types.gofails CI rather than at runtime.Side effect: complete badge
The previously-private
effectivePermissionsmirror inuserList.tswas missing thecancel-own:purchases/retry-own:purchases/approve-own:purchasesverbs thatDefaultUserPermissions()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 --noEmitclean.npx jest— 1688 tests pass (137 new acrosspermissions,plans-permissions,recommendations-permissions,riexchange-permissions,settings-permissions, plus the sidebar-button assertion inhtml.test.ts).npx webpack --mode productionbuilds the frontend bundle without errors.readonlyandusersessions browse the full app and see zero "admin access required" toasts during normal navigation.Summary by CodeRabbit
New Features
Style
Tests
Codegen follow-up (added 2026-05-13)
The original change hand-wrote
frontend/src/permissions.tsas a mirror of the Go defaults.permissions.test.tsenumerated 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.goimportsinternal/auth, readsDefaultAdminPermissions()/DefaultUserPermissions()/DefaultReadOnlyPermissions(), sorts entries by(action, resource)for a stable diff, and writesfrontend/src/permissions.generated.ts.Wrapper:
frontend/src/permissions.tskeeps the hand-writtenAction/Resourceclosed-union types and thecanAccess/isAdmin/getRolePermissionshelpers, importing the three permission sets from the generated file. Callers and tests are unchanged.Drift detection: a new
permissions-codegenhook in.pre-commit-config.yamlre-runs the generator andgit diff --exit-codeon the generated file. The hook triggers on changes tointernal/auth/types.go, the generator, or the generated file itself. CI catches the same drift via the existing pre-commit GitHub Actions workflow that runspre-commit run --all-files.How to regenerate after editing the Go defaults: