Skip to content

🛂 fix: Decide Admin Access Before the Panel Renders and Fail Closed on Errors - #185

Merged
danny-avila merged 3 commits into
mainfrom
pr/redo-admin-gate-before-layout
Oct 26, 2026
Merged

danny-avila merged 3 commits into
mainfrom
pr/redo-admin-gate-before-layout

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Pull Request Template

⚠️ Before Submitting a PR, Please Review:

  • Please ensure that you have thoroughly read and understood the Contributing Docs before submitting your Pull Request.

⚠️ Documentation Updates Notice:

  • Kindly note that documentation updates are managed in this repository: librechat.ai

Summary

This PR supersedes #109 and #101, both by @dustinhealy, and combines them on current main. The loader approach comes from #109 and the fail-closed states come from #101. Neither PR applies as it stands: #109 conflicts with main, and #101's return null while loading would blank the page where main now shows the AppPending shell.

The /_app layout decided admin access inside the component, after the page had already rendered:

if (!isLoading && !isError && !hasCapability(SystemCapabilities.ACCESS_ADMIN)) {
  return <AccessDenied />;
}

This caused two problems for a signed-in user without access:admin, for example a user whose grant was revoked within the 60-second session revalidation window:

  • Panel flash: while /api/admin/grants/effective was in flight, the layout rendered the whole panel (sidebar, header, dashboard with its Quick links) and only then switched to Access denied.
  • Fail-open on error: when the capabilities request failed with an error useCapabilities does not map (anything outside 401/403/404/503, such as a 500), !isError kept the guard from ever tripping. The panel stayed up for that user, on every page.

Admins had a smaller version of the same problem: their sidebar first rendered without Configuration and Access, and those items appeared once the capabilities arrived.

Changes

  • Shared query: useCapabilities.ts exports capabilitiesQueryOptions(userId), so the loader and the hook share one query definition and one cache entry. It keeps the existing 401/403 → no capabilities and 404/503/fetch failed → grants unavailable mapping, staleTime 30s and retry: false. useCapabilities() uses it, and its signature is unchanged. The { available, capabilities } shape is now t.CapabilitiesData in src/types/hooks.ts.
  • Loader: the /_app route loads the capabilities in its loader with fetchQuery, after beforeLoad has verified the session. A cached answer is used only while fresh (30 seconds); a stale one is fetched again before the layout renders, so returning to the panel after a revocation doesn't flash it either. If that fetch fails, the loader reports the capabilities as unverified and the layout shows "Could not verify permissions" instead of trusting the stale answer. Until the capabilities are known, the router shows the existing AppPending shell (pendingMs 300), so the layout first renders with the answer.
  • Gate from resolved data: AppLayout renders the panel only when the grants API is available and the capabilities imply access:admin. Everyone else gets AccessDenied, before any sidebar, header or page mounts.
  • Fail closed: when the capabilities cannot be loaded, the loader leaves the error in the query instead of throwing. AppLayout then shows PermissionsUnavailable with its Reload button, not the panel and not the generic error page. The query is not retried on mount, because the loader has just tried it.
  • Background refetches: a refetch that fails while the capabilities are known keeps the last answer. For example, EditCapabilitiesDialog invalidates ['effectiveCapabilities'] after a save, and the window-focus refetch runs once the query is stale. A failed refetch therefore cannot take an admin out of the panel or into the error page. A refetch that comes back without access:admin still replaces the panel with Access denied.
  • Dead code: removes effectiveCapabilitiesOptions from src/server/capabilities.ts. Nothing called it, and it cached a string[] under the same ['effectiveCapabilities', userId] key the hook stores { available, capabilities } under.

Not carried over

Change Type

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

Testing

New unit tests in src/routes/-tests/app.test.tsx render the real /_app route and dashboard under a memory router. A MutationObserver records every aside, header and main element that enters the document, including one that a later render removes again:

  • Pending: while the capabilities load, only the AppPending shell is shown, with no chrome.
  • Denied: capabilities without access:admin, a 403, or no grants API (404) each show Access denied, and no chrome mounts first.
  • Unavailable: a 500 shows "Could not verify permissions" with its Reload button. It shows neither the panel nor the error page, and the request is not repeated.
  • Allowed: an admin gets the panel, and the sidebar already lists Configuration when it first mounts.
  • Refetch failure: after a failed refetch, an admin stays in the panel.
  • Revoked on refetch: a refetch without access:admin replaces the panel with Access denied.
  • Stale cache: with stale admin capabilities cached and the grant revoked on the server, the route waits for the new answer and shows Access denied with no chrome. With stale capabilities cached and the request failing, it shows "Could not verify permissions", again with no chrome.
  • Loader: loading the route without rendering it fetches the capabilities and fills the shared query, so the request starts with the route's other loaders instead of after the layout mounts. It fails without the loader.

On main, the pending, three denied, unavailable and allowed tests fail. The two refetch tests pass on both; they guard the fix against a version that would throw admins out on a failed refetch.

Live check against a LibreChat backend and the production build. The test user's only admin access was a user-level access:admin grant. The grant was revoked, and the page was reloaded within the 60-second revalidation window. A MutationObserver installed before the app's scripts recorded when each element first appeared:

Case main This PR
Revoked, real timing sidebar, header and Quick links at 524 ms, Access denied at 532 ms shell at 24 ms, Access denied at 523 ms, no chrome
Revoked, capabilities delayed 1.5 s panel from 560 ms to 2071 ms, then Access denied shell only, Access denied at 1563 ms, no chrome
Revoked, capabilities request answers 500 panel stays up, no denial (also on /grants) "Could not verify permissions" with Reload, no chrome (also on /grants)
Grant restored, then Reload clicked n/a panel with its sidebar
Admin, stale capabilities refetched on focus and failing with 500 panel stays panel stays
Full admin, sidebar items over time Dashboard, Grants, Help, then Configuration and Access 14–39 ms later all five items on first paint

Test Configuration:

  • LibreChat dev backend, MongoDB 8.2 replica set.
  • The panel's production build (bun run build + bun server.ts), Chromium via Playwright.
  • npx tsc --noEmit, npx eslint src/ e2e/, npx prettier --check on the changed files, bun run test:lint-rules (19 pass), npx vitest run (153 files, 3323 tests).

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • I have written tests demonstrating that my changes are effective or that my feature works
  • Local unit tests pass with my changes

…n 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.
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 1. Head: 373f153

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T14:46:12.240373Z d4feaa4 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 373f1538de

ℹ️ 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/routes/_app.tsx Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 373f1538de

ℹ️ 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

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 2. Head: 6e9c5e6

@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: 6e9c5e66e6

ℹ️ 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/routes/_app.tsx Resolved
@danny-avila

Copy link
Copy Markdown
Contributor Author

@codex review

Pass 3. Head: d4feaa4

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: d4feaa40cc

ℹ️ 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
danny-avila merged commit 0b67b7d into main Oct 26, 2026
3 checks passed
danny-avila added a commit that referenced this pull request Oct 28, 2026
…kend (#190)

Since #185 the /_app route loads GET /api/admin/grants/effective before the
panel renders, and fails closed when it can't. e2e/mock-backend.mjs never
served that route, so every signed-in page in the mock e2e run showed "Could
not verify permissions". The mock now answers it for its admin tokens with
every capability in SystemCapabilities, refusing other requests the way it
refuses /api/admin/verify; both share one token check.
danny-avila added a commit that referenced this pull request Oct 28, 2026
…k and Update the Access and Grants Specs (#202)

* 🧪 fix: Serve the Admin's Effective Capabilities from the E2E Mock Backend

Since #185 the /_app route loads GET /api/admin/grants/effective before the
panel renders, and fails closed when it can't. e2e/mock-backend.mjs never
served that route, so every signed-in page in the mock e2e run showed "Could
not verify permissions". The mock now answers it for its admin tokens with
every capability in SystemCapabilities, refusing other requests the way it
refuses /api/admin/verify; both share one token check.

* 🧪 fix: Serve Roles, Groups, Grants and the Audit Log from the E2E Mock and Update the Access and Grants Specs

e2e/mock-backend.mjs served none of the admin API routes the Access and
Grants pages read, so most of access.spec.ts and grants.spec.ts failed
against it. The mock now holds in-memory fixtures (users, the ADMIN and USER
roles plus Auditor, Billing and Support, the Engineering and Marketing
groups, role grants and a hash-chained grant history) and answers the
roles, groups, user search, grants and audit-log routes in LibreChat's
response shapes. Group search is LibreChat's case-insensitive substring
match on name, email and description; user search matches word prefixes.
Writes change the fixtures, so create, edit and delete flows work. Path
parameters are matched per route and every admin route shares the existing
token check.

The specs also asserted UI the panel does not have: title-cased role names,
a searchbox role, kebab menus, keyboard reordering, a roles/groups type
filter and user grants. They now follow the current panel, wait for
filtered results instead of sleeping, and delete the roles and groups they
create. Under E2E_LIVE=1 the Groups-tab tests create the groups they list
and delete them afterwards, and the tests that read users or grant history
only the mock has are skipped.

* 🧪 fix: Name Live E2E Roles and Groups With UUIDs, Serve the Audit-Log CSV Export From the Mock and Test That a Role Rename Leaves Its Grants Behind

* 🧪 fix: Clean Up Live E2E Staging on Failure, Keep Audited Writes on the Mock and Find Staged Groups Past the First Page

* 🧪 fix: Serve One Audit-Log Entry by Id From the E2E Mock
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