Skip to content

🐛 fix: Resolve Admin Capability Gate In Route Loader - #109

Closed
dustinhealy wants to merge 2 commits into
mainfrom
agent/admin-loader-capability-gate
Closed

dustinhealy wants to merge 2 commits into
mainfrom
agent/admin-loader-capability-gate

Conversation

@dustinhealy

@dustinhealy dustinhealy commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

AppLayout decided admin access from a component-level query, so while /api/admin/grants/effective was in flight the layout fell through and rendered the full panel (sidebar, header, dashboard) before replacing it with the access-denied screen. A non-admin briefly sees the entire admin UI, including its navigation.

This resolves the capability check in the _app route loader with queryClient.ensureQueryData and reads it in the component with useSuspenseQuery, so the layout only ever renders a decided state: authorized or denied. The route's pendingComponent covers the wait. This is the pattern TanStack documents for data-derived route gating, for exactly this reason ("No flash of loading states").

Change Type

  • Bug fix (non-breaking change which fixes an issue)

What changed

  • useCapabilities.ts exports a capabilitiesQueryOptions(userId) factory so the loader and the hook share one query definition and one cache entry. The hook's signature and all existing consumers are unchanged.
  • _app.tsx prefetches that query in loader, renders AppPending while it resolves, and derives the admin decision synchronously via useSuspenseQuery. The isLoading/isError fall-through is gone.
  • __root.tsx uses createRootRouteWithContext<{ queryClient }> so the loader can reach the query client through route context. The router already supplies it; only the type was missing.
  • Removes effectiveCapabilitiesOptions from server/capabilities.ts. It had no callers anywhere in the repo, and it declared the same ['effectiveCapabilities', userId] cache key as the hook with an incompatible payload (string[] versus { available, capabilities }) and no 401/403/404 classification. Leaving both would mean whichever populated the key first won, and a consumer of the other would read the wrong shape.

Testing

Verified in a container against a mock of the admin API, driving the real panel through a genuine login. Because the flash is a sub-frame DOM transition, a MutationObserver installed before any app JS runs timestamps the first appearance of each marker; screenshots and polling are too coarse to catch it.

Before, on this branch's base, at real timing (no artificial latency):

Loading...                                        611ms
Welcome! / Quick links / Getting around / Ctrl+K   1105ms   <- full panel mounted
Access denied                                     1147ms

The panel is on screen for ~42ms for a user who is about to be denied.

Before: the full panel mounts while capabilities load, then is replaced by the access-denied screen.

clip-base-flash.webm

This clip was captured on a branch that also changes the access-denied screen itself, so its final frame differs from what main renders. The flashing panel is the same code path this PR fixes.

After, same harness:

Loading...     646ms
Access denied  1142ms
CHROME_MARKERS=[]

No privileged chrome enters the DOM at any point.

After: neutral loading only, then the access-denied screen.

02-flash-after

Local gates: eslint clean on the changed files, prettier --check clean, tsc --noEmit clean, and the full vitest suite passes.

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • My changes do not introduce new warnings
  • Local unit tests pass with my changes

The layout decided admin access from a component-level query, so while the
effective-capabilities request was in flight it fell through and mounted the
full panel before replacing it with the access-denied screen. Resolving the
check in the route loader and reading it with a suspense query means the
layout only ever renders a decided state, with the route pending component
covering the wait.

Also removes an unreferenced capabilities query factory that declared the
same cache key as the hook with an incompatible payload and no error
classification, so the two could not safely coexist.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cfe15fcbd6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/useCapabilities.ts Resolved
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: cfe15fcbd6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The exported query factory's data contract belongs with the rest of the local
type definitions rather than inside the hook, so future consumers reach for the
shared type instead of redefining the result shape.
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: ae1e92a430

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@dustinhealy
dustinhealy marked this pull request as ready for review August 7, 2026 15:56
@dustinhealy

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

Reviewed commit: ae1e92a430

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

danny-avila added a commit that referenced this pull request Oct 26, 2026
…n Errors (#185)

* 🛂 fix: Decide Admin Access Before the Panel Renders and Fail Closed on Errors

The /_app layout read the viewer's capabilities with a component query and showed AccessDenied
only once that query had succeeded. Until then it rendered the whole panel: sidebar, header and
the routed page. A signed-in user without access:admin, such as one whose grant was revoked within
the 60-second session revalidation window, saw the panel before the denial. When the capabilities
request failed with an error the hook does not map, such as a 500, the panel stayed up for them.

The /_app loader now loads the capabilities through capabilitiesQueryOptions(userId), the query
useCapabilities reads, so the layout first renders with the answer. An admin gets the panel,
anyone else gets AccessDenied, and a failed load shows PermissionsUnavailable with its Reload
button. The AppPending shell covers the wait. A failed background refetch keeps the last answer,
so an admin stays in the panel; a refetch without access:admin still takes the panel away. Admins
also get their full sidebar on first paint instead of Configuration and Access appearing after
load. The unused effectiveCapabilitiesOptions, which cached a different shape under the same
query key, is removed.

Supersedes #109 and #101 by @dustinhealy: the loader approach comes from #109 and the fail-closed
states from #101.

* 🛂 fix: Fetch Stale Capabilities Again Before the Panel Renders

* 🛂 fix: Fail Closed When the Loader Can't Re-Verify Stale Capabilities
@danny-avila

Copy link
Copy Markdown
Contributor

Thanks @dustinhealy for this. The flash you found still reproduced on main: after a grant was revoked, the sidebar, header and dashboard mounted before Access denied appeared.

#185, now merged, fixes it by combining this PR with #101 on current main. It keeps this PR's approach:

  • a shared capabilitiesQueryOptions factory;
  • loading the capabilities in the /_app route loader;
  • deciding the gate from resolved data;
  • removing the unused effectiveCapabilitiesOptions.

The root-context typing and a pending component were already on main, so those parts weren't carried over. This PR conflicts with main, so I'm closing it in favor of #185. If you see a case #185 misses, please comment or open an issue.

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.

2 participants