Addresses three issues in the migrated Team Info virtual keys table, all
pre-existing behavior carried over from the tremor version:
- Changing the sort now resets to page 1. Previously handleSortingChange
routed through handleFilterChange with skipDebounce=true, which skipped the
pageIndex reset, so sorting while on a later page asked the server for that
page of the newly sorted results (an arbitrary slice).
- Reset Filters now restores the default sort. It previously reset the filter
fields and page but never touched the sorting state that actually drives the
query, so the sort indicator and server order persisted.
- Removes the dead Sort By / Sort Order keys from the filters object; sort is
derived solely from the sorting state, so those keys were written but never
read. Sort now lives in one place.
Adds regression tests for the page-reset-on-sort and sort-reset-on-filter-reset
behaviors (both fail if either fix is reverted).
Colocation follow-up to the App Router migration: move each page's owned
components out of the shared src/components dump and into its route segment's
_components/ folder, draining the shared bucket. Convention: a component used
by exactly one segment goes in that segment's _components/ (private, matching
Next's _ route-exclusion); a component shared by 2+ segments stays in
@/components. No new _shared/ folder.
Rename-in-place (segment already had a local components/ folder):
- api-reference (also relocates the shared CodeBlock, used by playground and
cost-tracking, to @/components/CodeBlock)
- memory, budgets, access-groups
- caching, projects, guardrails-monitor
Extract from src/components (page view lived in the shared dump):
- AdminPanel -> admin-panel, organizations -> organizations,
general_settings -> router-settings, usage -> old-usage
Each folder/view was verified to have no importer other than its own page
(cross-checked across src, tests, and e2e_tests). Relative imports inside moved
single files are rewritten to absolute @/components/*; colocated tests move with
their subject and have their vi.mock paths rewritten to match. Grandfathered
lint suppressions (tremor, react-hooks, and similar, all pre-existing) are
re-keyed to the new paths with counts unchanged. No behavior change.
Drop the duplicate local DEFAULT_PAGE_SIZE_OPTIONS in DataTable.tsx and import
the one already exported from DataTablePagination.tsx, removing the divergence
risk if the canonical list changes.
Follow-up polish on the migrated Team Info virtual keys table: widen the Key
ID column by 20px (100 -> 120), nearly double Created By (70 -> 130) so the
name and popover fit, and remove the Last Active header info icon (and its now
unused InfoCircleOutlined import).
The preview endpoint infers client_credentials when the inherited client_id, client_secret, and
token_url are all present (common once DCR or discovery filled them) and then strips the forwarded
bearer to preview as M2M, so the staged interactive token was silently unused; sending
oauth2_flow=authorization_code bypasses the inference. spec_path now rides along so OpenAPI servers
take the spec-based preview path the create form gets. clearHeldOAuthToken also empties the tool
list, mirroring the create form's clearTools, so a preview fetched with the discarded token never
lingers while the refetch is in flight
The create and edit submit paths for true_passthrough and oauth_delegate persist only the tool
configuration: the parametrized create test authorizes, disables the allowlist, and asserts nothing
is persisted before submit, then that the create payload carries allowed_tools but no credentials
and no occurrence of the token anywhere in the serialized payload, no per-user DB credential is
written, and the token is committed to sessionStorage only, keyed to the created server. The edit
save test gains the same serialized-payload assertion
For authorization_code the edit preview listed tools by server_id only, relying on the stored
per-user DB credential, so a token authorized in the edit session gave an empty preview until the
admin saved; the create form previews the identical state through the config-based preview
endpoint, which takes the token explicitly. The edit fetch now routes through that same endpoint
when a staged interactive token is held, built from the form values with the saved record as
fallback, and keeps the by-server_id listing for every other case
The identity used to pick the audience from spec_path only when
values.transport was OPENAPI, but the create form keeps transport in component
state rather than form values, so spec_path edits on OpenAPI servers never
invalidated a held token. Comparing url and spec_path independently mirrors
the backend's mcp_oauth_token_identity and fires regardless of whether
transport is present. Invalidation now also wipes only credentials; the
admin-typed endpoint fields are kept
The staged access token never reaches formValues (it is not a registered form field), so the
assertion could not fail; the DCR client pair is the leak the test actually pins, proven by the
mutation run
Review follow-ups on the stale-token invalidation. The backend identity now decrypts client_id and
client_secret before comparing: the stored values are NaCl-encrypted with a fresh nonce on every
write, so comparing ciphertext flagged every routine save as a mint-relevant change and purged
per-user tokens that were still valid. The identity also gains spec_path, the audience for OpenAPI
servers, and parses credentials stored as a JSON string
The purge now routes each (user, server) through the manager's invalidate_user_oauth_token_cache,
which becomes the single invalidation point covering both the legacy per-user token cache and the
v2 per-user OAuth token store; previously the purge evicted only the legacy cache while the revoke
path evicted only the v2 store, so each path left the other cache serving a replaced token until
its TTL. A credential row racing in between the find and the delete is now detected via the
delete_many count and logged; its cache entry expires by TTL
On the dashboard, CLEARED_ON_INVALIDATION and the staleness check move to types.tsx as the single
shared implementation for both forms. The edit form's transport handler now rechecks the identity
after its programmatic setFieldsValue calls, which antd does not report through onValuesChange, so
a token no longer survives a transport switch that clears the mint target. The create form rebuilds
formValues from the post-reset form state after an invalidation instead of publishing the pre-reset
snapshot, so the tool preview can no longer refetch with the discarded DCR client. Both transport
handlers now share the recheck, which also stops the create form from over-invalidating on an
http to sse swap that keeps the same url and therefore the same audience
An admin who ran Authorize & Fetch and then changed a field that determines which upstream OAuth
token gets minted kept using the stale token for tool preview, sessionStorage, and (on the backend)
the stored per-user credential and its cache. Grounded in RFC 8707/8693 and the MCP auth spec, a token
is bound to one tuple: resource/audience (url), OAuth mode/grant (auth_type, oauth_flow_type), the
authorization-server endpoints, and the OAuth client + scopes. A shared getOAuthAuthorizationIdentity
captures exactly those fields; transport (http/sse on the same url is the same audience) and
delegate_auth_to_upstream (a downstream-usage toggle never sent to the authorize request) are excluded.
UI: both the create and edit forms now discard the held token (React state / sessionStorage / hook,
plus the fetched token + DCR client in form.credentials) whenever the identity diverges from the one it
was authorized against, re-applying the admin's in-flight edit so it is never wiped. The check lives in
one shared helper so the two forms cannot drift.
Backend: editing an MCP server now compares the pre/post identity and, on a mint-relevant change, purges
every stored per-user OAuth credential for the server (DB row + per-user token cache) so no user
forwards a token minted for a resource/AS/client that no longer matches. Best-effort; a purge failure
never fails the update.
Second proof-of-concept consumer for the shared DataTable. Replaces the
hand-rolled tremor table in the Team Info Virtual Keys tab with DataTable in
server-sort and server-pagination mode plus column resizing; the file drops
about 150 lines. Sortable headers now use DataTableSortHeader, pagination is
a detached DataTablePagination driven by the page state, the id-cell still
opens the key drawer, and the body scrolls under a sticky header via
maxBodyHeight. Two behavior changes: the pagination control is the
standardized bar (row range plus page-size select) rather than the old
Previous/Next buttons, and a sort header cycles ascending/descending without
a third unsorted state, which also removes a latent case where clearing the
sort left the server sorted.
Updates the TeamVirtualKeysTable and TeamInfo tests to the new pagination,
adds a test that a sort-header click routes to useKeys as a server sort, and
lowers the no-large-inline-object-arg metric by one and the file's
no-nested-ternary suppression from two to one to match the leaner code.
Proof-of-concept consumer for the shared DataTable added in the previous
commit. Swaps the antd Table in the Workflow Runs page for DataTable in
client-pagination mode, keeping the existing cell renderers, row-click
drawer, and empty state. Adds a focused test that the rows render through
DataTable, a row click routes the detail fetch to the correct run, and the
empty state shows.
Phase 0 of the dashboard table-standardization effort: one composable
DataTable built on TanStack react-table and the shadcn-style primitives in
components/ui/table.tsx (Base UI, Tailwind v4), plus its behavioral test
suite. No existing tables are migrated in this change.
The component owns the TanStack instance and a shadcn shell, and exposes
composable slots (toolbar, pagination, footer) plus DataTableToolbar,
DataTablePagination, DataTableViewOptions, and DataTableSortHeader. Sorting
and pagination each use a single mode enum (none/client/server) so server
modes only surface state via callbacks and never reorder or slice locally.
columnMeta.ts defines the canonical ColumnMeta augmentation. The rendering
shell imports only components/ui/table primitives; no tremor or antd.
The edit form decided auth mode from the saved mcpServer.auth_type in fetchTools while the
authorize flow used the current form value, so a token authorized after switching the form to a
client-forwarded mode was never forwarded as the x-mcp header until the server was saved. A shared
getEffectiveAuthType (form value falling back to the saved record) is now the single decision point
for token receipt and tool loading
The save path classified the staged token with getMcpOAuthMode, which returns null for
true_passthrough and oauth_delegate, so the staged token was dropped on save instead of being
committed to sessionStorage the way the create form's submit path does. The passthrough branch now
also covers the client-forwarded modes; the token still never enters the server row
The App Router migration is complete: every page is a path route and the
legacy `?page=` switch is gone from the index. This closes it out.
The `/ui/` index (page.tsx) kept its own duplicate copy of teams state, a
teams fetch, and keys/addKey plumbing solely to feed a second `UserDashboard`
render for the `invitation_id` case. That was redundant: `ApiKeysDashboard`
already renders `UserDashboard` sourcing its own data, so the index is thinned
to just render `<ApiKeysDashboard />`. The login redirect, the legacy `?page=`
deep-link redirect for old bookmarks, and the post-login return-URL handling
stay on the index.
The invitation entry point now resolves in one place. Modern invitation links
already point at the dedicated `/onboarding` route; the dashboard layout now
redirects legacy `/ui/?invitation_id=` links there too (via `migratedHref`,
the same base-aware redirect the index uses for `?page=`), instead of
re-rendering that route's page component inline. This removes an import of one
route's `page.tsx` into another module, and lets the now-unreachable
`if (invitation_id) return <Onboarding/>` branch in the shared
`user_dashboard.tsx` be deleted along with its dead `Onboarding` import and
`searchParams` read. A layout test asserts the redirect and fails if it
regresses.
`legacyPageHref` and the sidebar's migrated-vs-legacy href fallback are left
in place; they are still live for the parent-category nav nodes (agentic,
tools, experimental, settings) that are not page routes.
eslint-metrics.json is resynced: -2 no-explicit-any from the removed `any`
casts, plus pre-existing drift the gate requires the snapshot to match.
* fix(ui): forward refs through ui primitives and fail tests on swallowed refs
Under React 18 a ref passed to a plain function component is dropped
with only a dev console warning, so Base UI render-prop triggers
composed over our shadcn-style primitives silently stop working (the
tooltip just never opens; ui/badge.tsx hit exactly this on the shared
DataTable branch). Label, Separator, Skeleton, UiLoadingSpinner and the
Table family now use React.forwardRef like Button and Input already
did, a contract test pins ref delivery for each, and setupTests turns
React's ref warning into a test failure so the next primitive that
swallows a ref fails CI instead of shipping a dead tooltip
* fix(ui): include captured ref warnings in the tripwire error
The afterEach tripwire threw a fixed message and discarded the collected
React warnings, so a failure never said which component swallowed the ref.
Append the captured warnings (component name + stack) to the thrown error.
The expanded reasoning block did not constrain its width or break long unbreakable tokens, so its inline-block bubble grew past its max width and pushed the whole page wider (#32481). Mirror the message body handling by capping the container width and breaking long words/code.
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The create/edit forms passed several large object literals inline as arguments (persist-state
JSON.stringify, storeMCPOAuthUserCredential, setToken, transport-clear setFieldsValue), tripping
local/no-large-inline-object-arg. Assigned each to a named variable at the call site - a pure,
behavior-preserving refactor verified by the create/edit suites - which lowers the whole-tree count so
the eslint baseline is 512 rather than being raised to accommodate them.
The create form wrote the upstream token obtained by Authorize & Fetch into
form.credentials for every mode, so for true_passthrough / oauth_delegate the
browser-held token leaked into the OAuth flow's getCredentials (preview requests)
and the redirect-persist cache, and was a step away from server-level credential
persistence. onTokenReceived now early-returns for the client-forwarded modes,
holding the token only in local state for preview (mirroring the edit form),
instead of writing it into form.credentials.
The 'auth_type is true_passthrough or oauth_delegate' check was duplicated inline
across both server forms' browser-authorize temp payloads, the edit form's
onTokenReceived and tool-preview gate, PassthroughAuthorizeSection, and mcp_tools'
usesBrowserHeldToken. Extracted a single isClientForwardedTokenMode helper in
types.tsx and routed every site through it so the set of client-forwarded modes
lives in one place and cannot drift. Also replaced a pre-existing nested ternary
in the authorize button label surfaced by touching the file.
The edit form's getTemporaryPayload read the server's stored auth_type instead
of the value the admin selected in the dropdown, so an admin who switched an
existing oauth2 server to true_passthrough (or oauth_delegate) and ran the
browser authorize flow built the temporary OAuth-relay server as oauth2. That
made needs_user_oauth_token true and persisted the token to the DB, contrary to
the mode's browser-held contract, and left it inconsistent with onTokenReceived
and the submit payload, both of which already read the form value. It now reads
values.auth_type, matching the create form.
Two correctness fixes for the client-forwarded token modes.
The preemptive-401 connect gate for true_passthrough and oauth_delegate
only inspected the request-wide Authorization, so a caller who bound the
upstream token via the per-server x-mcp-{alias}-authorization header (the
mandatory shape in a multi-server aggregate, where the request-wide
Authorization is withheld) was spuriously 401'd at connect even though
egress already honors that header. The gate now recognizes the per-server
header for both modes via a shared helper, mode-correctly: true_passthrough
treats any Authorization or the per-server header as the upstream token,
oauth_delegate keeps requiring a distinct x-litellm-api-key so a lone
Authorization consumed for admission is never mistaken for an upstream
token. The preemptive raise is also gated to single-server scopes so a
multi-server aggregate degrades gracefully (the listing absorbs a
per-server failure) instead of one missing token 401-ing the whole connect.
The browser-only Authorize flow was writing the upstream access and refresh
token to LiteLLM_MCPUserCredentials, contradicting the modes' persist-nothing
contract: the temp OAuth-relay server was cached with a hardcoded oauth2
auth_type, so needs_user_oauth_token was true and the token exchange stored
it. The create and edit forms now send the real auth_type for these modes,
so the temp server is not oauth2, needs_user_oauth_token is false, and the
exchange skips storage while still returning the token to the browser
session.
The server detail page's Tool Testing Playground gated its browser-held
token handling on the legacy PKCE-passthrough shape, so a
true_passthrough or oauth_delegate server listed tools unauthenticated
and surfaced 'Failed to fetch MCP tools' with no way to authorize. The
playground now treats both modes as browser-held-token servers: it
reads the sessionStorage token established by the create/edit
browser-only Authorize, forwards it via the x-mcp-{alias}-authorization
header, evicts it on a 401, and shows its own Authorize gate when the
token is absent.
That gate's flow uses the gateway's relayed authorize/register/token
endpoints with the real server id, which previously 400ed for anything
but oauth2. Those endpoints now also accept the client-forwarded token
modes (the minted token is upstream-audienced and browser-held; DCR
persistence stays off on this path), and registry builds run the same
RFC 9728/8414 endpoint discovery for these modes that oauth2 rows get,
since their rows never store an authorization_url.
true_passthrough and oauth_delegate persist no upstream credentials, so
the create/edit forms had no way to preview tools or configure the tool
allowlist: tools/list went upstream unauthenticated and came back 401.
This reuses the existing OAuth authorize machinery in browser-only mode
for those two auth types: the admin authorizes against the upstream
(DCR/PKCE, with optional client credentials for IdPs without dynamic
registration), the token lands in sessionStorage exactly like the
legacy PKCE-passthrough path, and the tools preview forwards it via the
per-server x-mcp-{alias}-authorization header, which the passthrough
resolver arm already accepts. Nothing is written to the server row or
the per-user credential store; the create payload keeps excluding
credentials for these auth types via AUTH_TYPES_REQUIRING_CREDENTIALS.
The tools preview endpoint now also extracts the Authorization header
for the two new auth types so the browser-held token reaches the
passthrough arm during create-time previews.
Adds the two client-forwarded token modes to the MCP server create and
edit form auth dropdowns, and shows a warning when true_passthrough is
selected: the gateway performs no admission auth for that server, so
callers reach the upstream without a LiteLLM key and per-key/per-team
rate limits and spend tracking do not apply. The warning is a shared
component so the two forms cannot drift on the copy.
* refactor(ui): point invitation links at the dedicated /onboarding route
Invitation and reset-password links were built as /ui?invitation_id=..., which lands on the dashboard index and renders the onboarding form inline. They now point at the standalone /ui/onboarding route, so the index no longer has to special-case invitations. Old links keep working unchanged; the index still renders onboarding inline for ?invitation_id until the migration closeout removes that branch.
Updates the three generators (the enterprise email builder, bulk user create, and the invitation/reset-password modal) and extracts the modal's URL building into a pure, unit-tested buildOnboardingUrl
Refs LIT-3687
* refactor(ui): guard buildOnboardingUrl against a missing invitation id
Return "" instead of emitting an invitation_id=undefined link when the id is not yet available, matching the existing empty-baseUrl guard. Placed after the SSO branch so the SSO link, which does not use the id, is unaffected
Refs LIT-3687
* feat(ui): add shared table_cells kit and convert logs columns
DateCell, MoneyCell, IdCell and StatusBadge consolidate the duplicated
per-table cell implementations behind one component each. The logs page
columns are the reference conversion; the dead auditLogColumns export
(superseded by audit_logs.tsx) is removed with it
* refactor(ui): consolidate table cells onto the shared table_cells kit
106 cell sites across 44 table files converge onto DateCell, MoneyCell,
IdCell and StatusBadge, replacing 8 date formats, 6 spend formats, 7 id
truncation strategies and 6 status badge styles with one implementation
each. Badge now forwards refs so Base UI tooltip triggers composed over
it can attach (they previously never opened under React 18). TimeCell
is deleted; its two consumers now render DateCell
* fix(ui): suppress cost tooltip for zero spend and drop dead getStatusBadge param
The logs Cost tooltip showed the raw $0 over a "-" cell for zero or
null spend (pre-existing, surfaced by review); the tooltip now only
renders when there is a real amount. healthCheckColumns no longer takes
the unused getStatusBadge callback and its dead definition is removed
* fix(ui): restyle StatusBadge as tinted pill matching the prior antd Tag look
* fix(ui): keep StatusBadge fully rounded like the other kit pills
Staging recently added the local eslint rules no-large-inline-object-arg and
no-long-condition-chain and tightened no-nested-ternary to an error. After
merging staging, the license-banner code tripped them: the banner's tiered
description was a nested ternary (now an error), and two option objects were
passed inline (adding budget debt). Extract the description into an
early-return helper, and hoist the useQuery options and the date-format options
into named constants. No behavior change; keeps the inline-object-arg count at
the committed baseline rather than bumping it
The layout test renders DashboardShell without a QueryClientProvider and mocks
DebugWarningBanner to null for exactly that reason. The new LicenseExpiryBanner
also uses a React Query hook, so it needs the same treatment; without it the
test threw "No QueryClient set". Runtime is unaffected: the app mounts a
QueryClientProvider above the layout (DebugWarningBanner already relies on it)
* feat(ui): add eslint rules for nested ternaries, large inline object args, and long condition chains
Adds three dashboard lint rules to keep new code readable. Nested ternaries
are banned outright via the built-in no-nested-ternary, with the 265 existing
occurrences grandfathered in eslint-suppressions.json so only new ones fail.
Two custom rules ship as a small local plugin under scripts/eslint-rules:
no-large-inline-object-arg flags object literals with 4+ properties passed
straight into a call, nudging toward a named variable, and no-long-condition-chain
flags boolean expressions that combine 4+ conditions, nudging toward a named
boolean. Both are warnings tracked on the existing budget ratchet
(eslint-budgets.json + eslint-metrics.json) with headroom above the current
counts, so they ratchet down over time rather than freezing a baseline. Both
thresholds are configurable rule options and covered by RuleTester unit tests.
* fix(ui): scope no-long-condition-chain to boolean operators, not nullish
Greptile flagged that the rule counted nullish-coalescing chains the same as
&&/|| chains, so a 4-part `a ?? b ?? c ?? d` fallback surfaced "Boolean
expression combines 4 conditions", which is inaccurate since a `??` fallback
is value defaulting, not a condition. Restrict the visitor to && / || nodes so
`??` chains are treated as leaves, while a boolean chain nested inside a `??`
is still caught. Drops 6 miscounted occurrences (240 -> 234).
* chore(ui): sync lint metrics and suppressions with staging
Merge advanced the base branch, adding one no-large-inline-object-arg
occurrence (508 -> 509) and making one grandfathered react-hooks suppression
stale. Regenerate eslint-metrics.json and prune the suppression so the
budget/drift gate passes.
* chore(ui): sync lint metrics with staging
Merge advanced the base, adding four no-large-inline-object-arg occurrences
(509 -> 513). Regenerate eslint-metrics.json so the drift gate passes.
UsageIndicator was fetching /health/license through its own useEffect while
the new expiry banner fetches the same endpoint via useLicenseInfo, so an admin
with the usage widget open made two identical calls per page load. Point
UsageIndicator at useLicenseInfo too; both callers now share one React Query
cache entry, collapsing it back to a single request. The null/error semantics
are preserved (data ?? null matches the previous catch-to-null), and license
errors never fed the widget's error state before either
* test(ui): characterize DataTable behavior before shadcn reskin
Pins the shared view_logs DataTable contract with library-agnostic
queries ahead of the tremor-to-shadcn table migration: loading and
empty states, TanStack column defs with custom cell renderers,
onRowClick payload, both expansion render paths (colspan sub-component
and sibling child rows), the getRowCanExpand gate, and client-side
sorting on and off. These must pass unchanged after the reskin.
* refactor(ui): reskin shared DataTable from tremor onto shadcn table primitives
Swaps the view_logs DataTable's presentational layer from @tremor/react
to the in-repo components/ui/table primitives and hardens the seam that
every later table migration copies:
- getRowId is injected instead of hardcoded to request_id through an
any cast; identity defaults to the row index and the logs page now
passes request_id explicitly, keeping expansion state attached to the
right row across refetch reorders
- one expansion render path: renderChildRows had zero consumers and is
removed; renderSubComponent (colspan cell) is the single path
- the four consumers passing dead no-op renderSubComponent and
getRowCanExpand boilerplate drop it
- loading and empty defaults become generic (Loading... / No results)
instead of log-specific
The characterization tests from the previous commit pass unchanged
except the dead child-rows path test, replaced by a reorder-stability
test for injected getRowId plus coverage of the new generic defaults.
First tremor removal of the tables track; view_logs/table.tsx no longer
imports @tremor/react.
* test(ui): assert child rows hidden before expansion in DataTable test
* fix(ui): suppress row hover on DataTable placeholder rows
* feat(ui): polish DataTable with skeleton loading, header band, and numeric column alignment
* feat(ui): shape DataTable skeletons per column and keep stale rows during refetch
* revert(ui): drop DataTable skeleton loading, restore text loading row
* fix(ui): clip DataTable to its rounded wrapper and right-align Duration/TTFT values
Surfaces a persistent, tiered banner under the dashboard navbar when an airgapped enterprise license is close to expiring: an amber, session-dismissible warning within 30 days, a non-dismissible red alert within 7 days, and a non-dismissible red banner once the date has passed. It reads the existing /health/license endpoint, so no backend change is needed, and is driven strictly by expiration_date; community and remote-validated instances that report no date show nothing. Shared day-count math is extracted to licenseUtils so the banner and the existing UsageIndicator widget stay in sync
Add a tag_rpm_limit field to virtual keys so each request tag gets its own independent RPM counter on the v3 rate limiter. A key configured with per-tag limits tracks each tag/group separately, and requests whose tag has no configured limit fall back to the key-level limit. Includes the dashboard UI to manage per-tag limits on key create and edit.
Resolves LIT-3147