Skip to content

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

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

馃悰 fix: Resolve Admin Capability Gate In Route Loader#109
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 Outdated
@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".

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