From f6fc6d299a7daa6fd405c9ddd0f4fabdc4b90432 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 22 Jul 2026 17:49:14 -0700 Subject: [PATCH 1/4] feat(ui): add standalone /connect route for MCP OAuth The MCP connect surface only existed as the Integrations tab inside the enable_chat_ui-gated /chat shell, so a keyless SSO user was bounced to the dashboard and could never reach it unless an admin enabled Chat UI first. Add a sibling /connect route with its own thin, auth-only layout that renders the same MCPAppsPanel without the chat-ui gate or chat shell. The user OAuth flow already returns to whatever URL started it, so no backend changes are needed. The chat playground and its gate are left unchanged. --- .../src/app/connect/layout.test.tsx | 65 +++++++++++++++++++ .../src/app/connect/layout.tsx | 20 ++++++ .../src/app/connect/page.test.tsx | 55 ++++++++++++++++ ui/litellm-dashboard/src/app/connect/page.tsx | 36 ++++++++++ 4 files changed, 176 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/connect/layout.test.tsx create mode 100644 ui/litellm-dashboard/src/app/connect/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/connect/page.test.tsx create mode 100644 ui/litellm-dashboard/src/app/connect/page.tsx diff --git a/ui/litellm-dashboard/src/app/connect/layout.test.tsx b/ui/litellm-dashboard/src/app/connect/layout.test.tsx new file mode 100644 index 00000000000..795a79d77e3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/layout.test.tsx @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ConnectLayout from "./layout"; + +const { mockUseAuthorized, state } = vi.hoisted(() => { + const state = { + accessToken: "token-123" as string | null, + isAuthorized: true, + isLoading: false, + }; + return { + state, + mockUseAuthorized: vi.fn(() => ({ + accessToken: state.accessToken, + isAuthorized: state.isAuthorized, + isLoading: state.isLoading, + })), + }; +}); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: mockUseAuthorized })); +vi.mock("@/components/navbar", () => ({ default: () =>
})); +vi.mock("@/contexts/ThemeContext", () => ({ + ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe("ConnectLayout", () => { + afterEach(() => { + state.accessToken = "token-123"; + state.isAuthorized = true; + state.isLoading = false; + }); + + it("renders the connect surface for an authorized user without any chat-ui flag", () => { + render( + +
+ , + ); + expect(screen.getByTestId("navbar")).toBeInTheDocument(); + expect(screen.getByTestId("page-content")).toBeInTheDocument(); + }); + + it("renders nothing when the user is not authorized", () => { + state.isAuthorized = false; + render( + +
+ , + ); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + }); + + it("renders nothing while authorization is still loading", () => { + state.isLoading = true; + render( + +
+ , + ); + expect(screen.queryByTestId("page-content")).not.toBeInTheDocument(); + expect(screen.queryByTestId("navbar")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/connect/layout.tsx b/ui/litellm-dashboard/src/app/connect/layout.tsx new file mode 100644 index 00000000000..63b1c484094 --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/layout.tsx @@ -0,0 +1,20 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import Navbar from "@/components/navbar"; +import { ThemeProvider } from "@/contexts/ThemeContext"; + +export default function ConnectLayout({ children }: { children: React.ReactNode }) { + const { accessToken, isAuthorized, isLoading } = useAuthorized(); + + if (isLoading || !isAuthorized) return null; + + return ( + +
+ +
{children}
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/connect/page.test.tsx b/ui/litellm-dashboard/src/app/connect/page.test.tsx new file mode 100644 index 00000000000..07b0e7a305a --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/page.test.tsx @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import ConnectPage from "./page"; + +interface PanelProps { + accessToken: string; + selectedServers: string[]; + onChange: (servers: string[]) => void; +} + +const { mockReplace, mockPanel, state } = vi.hoisted(() => { + const state = { + oauthReturn: null as string | null, + }; + return { + state, + mockReplace: vi.fn(), + mockPanel: vi.fn((_props: PanelProps) =>
), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => ({ get: (key: string) => (key === "mcpOauthReturn" ? state.oauthReturn : null) }), +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: () => ({ accessToken: "token-123" }), +})); +vi.mock("@/components/chat/MCPAppsPanel", () => ({ default: mockPanel })); + +describe("ConnectPage", () => { + afterEach(() => { + state.oauthReturn = null; + mockReplace.mockClear(); + mockPanel.mockClear(); + }); + + it("renders the MCP connect panel with the user's access token", () => { + render(); + expect(screen.getByTestId("mcp-apps-panel")).toBeInTheDocument(); + expect(mockPanel.mock.calls[0][0]).toMatchObject({ accessToken: "token-123", selectedServers: [] }); + }); + + it("strips the mcpOauthReturn param from the URL after an OAuth return", () => { + state.oauthReturn = "apps"; + window.history.replaceState({}, "", "/connect?mcpOauthReturn=apps"); + render(); + expect(mockReplace).toHaveBeenCalledWith("/connect"); + }); + + it("does not rewrite the URL when there is no OAuth return param", () => { + render(); + expect(mockReplace).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/connect/page.tsx b/ui/litellm-dashboard/src/app/connect/page.tsx new file mode 100644 index 00000000000..84770915e46 --- /dev/null +++ b/ui/litellm-dashboard/src/app/connect/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import MCPAppsPanel from "@/components/chat/MCPAppsPanel"; + +function ConnectPageContent() { + const { accessToken } = useAuthorized(); + const [selectedServers, setSelectedServers] = useState([]); + const router = useRouter(); + const searchParams = useSearchParams(); + const oauthReturn = searchParams.get("mcpOauthReturn"); + + useEffect(() => { + if (oauthReturn) { + const url = new URL(window.location.href); + url.searchParams.delete("mcpOauthReturn"); + router.replace(url.pathname + url.search); + } + }, [oauthReturn, router]); + + return ( +
+ +
+ ); +} + +export default function ConnectPage() { + return ( + + + + ); +} From 9e7b1c0b3617da0d26706d794609dd8aec0e4ffa Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 22 Jul 2026 18:15:37 -0700 Subject: [PATCH 2/4] feat(ui): land keyless users on the connect page after login A standalone /ui/connect route is only reachable if something points a user at it. Post-login the dashboard always rendered the API-keys view, so a keyless SSO user saw an empty dashboard and no path to connect. Redirect to /ui/connect from the dashboard landing when the URL carries ?login=success, the user is not an admin, and their key list is empty. Gating on the post-login marker keeps the dashboard reachable afterwards, and an explicit stored return URL still wins. useKeys takes an optional enabled flag so the lookup only runs on that landing. --- .../src/app/(dashboard)/hooks/keys/useKeys.ts | 3 +- .../src/app/(dashboard)/page.test.tsx | 111 ++++++++++++++++++ .../src/app/(dashboard)/page.tsx | 24 +++- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 198058803eb..0df809bc582 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -101,13 +101,14 @@ export const useKeys = ( page: number, pageSize: number, options: KeyListCallOptions = {}, + enabled: boolean = true, ): UseQueryResult => { const { accessToken } = useAuthorized(); return useQuery({ queryKey: keyKeys.list({ page, limit: pageSize, ...options }), queryFn: async () => await keyListCall(accessToken!, page, pageSize, options), - enabled: Boolean(accessToken), + enabled: Boolean(accessToken) && enabled, staleTime: 30000, // 30 seconds placeholderData: keepPreviousData, }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx new file mode 100644 index 00000000000..6fb64333089 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import CreateKeyPage from "./page"; + +interface KeyRow { + token: string; +} + +const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => { + const state = { + login: "success" as string | null, + userRole: "Internal User", + keys: [] as KeyRow[], + keysLoading: false, + returnUrl: null as string | null, + }; + return { + state, + mockReplace: vi.fn(), + mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`), + mockUseKeys: vi.fn((_page: number, _size: number, _opts: unknown, _enabled: boolean) => ({ + data: state.keysLoading ? undefined : { keys: state.keys, total_count: state.keys.length }, + isLoading: state.keysLoading, + })), + }; +}); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: mockReplace }), + useSearchParams: () => ({ get: (key: string) => (key === "login" ? state.login : null) }), +})); +vi.mock("@/contexts/AuthContext", () => ({ + useAuth: () => ({ + authLoading: false, + token: "tok", + userRole: state.userRole, + userID: "user-1", + }), +})); +vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys })); +vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ + default: () =>
, +})); +vi.mock("@/components/common_components/LoadingScreen", () => ({ + default: () =>
, +})); +vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" })); +vi.mock("@/utils/migratedPages", () => ({ MIGRATED_PAGES: {}, migratedHref: mockMigratedHref })); +vi.mock("@/utils/returnUrlUtils", () => ({ + buildLoginUrlWithReturn: (u: string) => u, + consumeReturnUrl: () => state.returnUrl, + getLoginUrl: () => "/login", + isValidReturnUrl: () => true, + normalizeUrlForCompare: (u: string) => u, + storeReturnUrl: () => undefined, +})); + +describe("dashboard landing keyless redirect", () => { + afterEach(() => { + state.login = "success"; + state.userRole = "Internal User"; + state.keys = []; + state.keysLoading = false; + state.returnUrl = null; + mockReplace.mockClear(); + mockUseKeys.mockClear(); + mockMigratedHref.mockClear(); + }); + + it("sends a keyless non-admin to the connect page after login", () => { + render(); + expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/connect"); + expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + }); + + it("leaves an admin with no keys on the dashboard", () => { + state.userRole = "Admin"; + render(); + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + }); + + it("leaves a user who already has a key on the dashboard", () => { + state.keys = [{ token: "sk-abc" }]; + render(); + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + }); + + it("does not redirect outside the post-login landing, and skips the key lookup entirely", () => { + state.login = null; + render(); + expect(mockReplace).not.toHaveBeenCalled(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(mockUseKeys.mock.calls[0][3]).toBe(false); + }); + + it("holds the loading screen while the key lookup is in flight", () => { + state.keysLoading = true; + render(); + expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("yields to an explicit return URL instead of the connect redirect", () => { + state.returnUrl = "/ui/models-and-endpoints"; + render(); + expect(mockReplace).not.toHaveBeenCalledWith("/mocked-ui/connect"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 02b2ccf5357..883aff8b36b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -3,6 +3,8 @@ import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { proxyBaseUrl } from "@/components/networking"; +import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { isAdminRole } from "@/utils/roles"; import { useAuth } from "@/contexts/AuthContext"; import { buildLoginUrlWithReturn, @@ -17,7 +19,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef } from "react"; function CreateKeyPageContent() { - const { authLoading, token } = useAuth(); + const { authLoading, token, userRole, userID } = useAuth(); const router = useRouter(); const searchParams = useSearchParams()!; @@ -26,6 +28,7 @@ function CreateKeyPageContent() { // Track if we've already attempted a return URL redirect to prevent race conditions const hasAttemptedReturnRedirectRef = useRef(false); + const didReturnRedirectRef = useRef(false); const redirectToLogin = authLoading === false && token === null; @@ -75,6 +78,7 @@ function CreateKeyPageContent() { // Only redirect if the return URL is different from the current URL // This prevents infinite redirect loops if (normalizedReturnUrl !== normalizedCurrentUrl) { + didReturnRedirectRef.current = true; window.location.replace(safeUrl.href); } } @@ -83,10 +87,26 @@ function CreateKeyPageContent() { useEffect(() => { if (!token) { hasAttemptedReturnRedirectRef.current = false; + didReturnRedirectRef.current = false; } }, [token]); - if (authLoading || redirectToLogin || isLegacyRedirect) { + const isPostLoginLanding = searchParams.get("login") === "success"; + const isSignedIn = !authLoading && Boolean(token); + const shouldCheckForKeys = isPostLoginLanding && isSignedIn && !isAdminRole(userRole); + const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys); + const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0; + const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding; + + useEffect(() => { + if (isKeylessLanding && !didReturnRedirectRef.current) { + router.replace(migratedHref("connect")); + } + }, [isKeylessLanding, router]); + + const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingKeylessLanding; + + if (authLoading || isRedirecting) { return ; } From 8929e09f4927a65672a161337f365e8546adfaf4 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 23 Jul 2026 09:56:01 -0700 Subject: [PATCH 3/4] fix(ui): gate the keyless connect redirect on internal-user roles isAdminRole compares against a list that mixes raw and formatted role strings: it holds raw org_admin but not the "Org Admin" that formatUserRole produces, and AuthContext stores the formatted form. A keyless org admin therefore read as a non-admin and was redirected to the connect page. Gate positively on internalUserRoles instead, which carries both representations, so the redirect targets the persona it is meant for and any role that is not unambiguously an internal user is left on the dashboard. The shared admin list is left alone: completing it would change org-admin access across every isAdminRole caller, which is a roles-policy decision of its own. --- ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx | 7 ++++--- ui/litellm-dashboard/src/app/(dashboard)/page.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index 6fb64333089..affcca401f2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -67,14 +67,15 @@ describe("dashboard landing keyless redirect", () => { mockMigratedHref.mockClear(); }); - it("sends a keyless non-admin to the connect page after login", () => { + it.each(["Internal User", "Internal Viewer"])("sends a keyless %s to the connect page after login", (role) => { + state.userRole = role; render(); expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/connect"); expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); }); - it("leaves an admin with no keys on the dashboard", () => { - state.userRole = "Admin"; + it.each(["Admin", "Admin Viewer", "Org Admin"])("leaves a keyless %s on the dashboard", (role) => { + state.userRole = role; render(); expect(mockReplace).not.toHaveBeenCalled(); expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 883aff8b36b..682ba0bbcb2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -4,7 +4,7 @@ import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { proxyBaseUrl } from "@/components/networking"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import { isAdminRole } from "@/utils/roles"; +import { internalUserRoles } from "@/utils/roles"; import { useAuth } from "@/contexts/AuthContext"; import { buildLoginUrlWithReturn, @@ -93,7 +93,7 @@ function CreateKeyPageContent() { const isPostLoginLanding = searchParams.get("login") === "success"; const isSignedIn = !authLoading && Boolean(token); - const shouldCheckForKeys = isPostLoginLanding && isSignedIn && !isAdminRole(userRole); + const shouldCheckForKeys = isPostLoginLanding && isSignedIn && internalUserRoles.includes(userRole); const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys); const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0; const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding; From 531854db4eb8ad04e50330ef5c6bdc5389aea9d1 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 23 Jul 2026 10:53:59 -0700 Subject: [PATCH 4/4] fix(ui): hold the landing until the role hydrates before deciding the redirect AuthContext sets token and clears authLoading in one effect, then a second token-keyed effect populates userRole, so there is a render where the user is signed in but userRole is still the initial empty string. The positive internalUserRoles check reads that interim role as non-internal, which let the api-keys dashboard paint for a frame before the role arrived and the keyless redirect ran. Treat "signed in on the post-login landing with an unhydrated role" as a resolving state that holds the loading screen, so the dashboard never flashes. Every login=success token carries a required user_role claim, so the role always hydrates within a tick and this cannot hang; it is scoped to the landing, so ordinary dashboard visits are unaffected. --- .../src/app/(dashboard)/page.test.tsx | 15 +++++++++++++++ ui/litellm-dashboard/src/app/(dashboard)/page.tsx | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index affcca401f2..89975f231aa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -96,6 +96,21 @@ describe("dashboard landing keyless redirect", () => { expect(mockUseKeys.mock.calls[0][3]).toBe(false); }); + it("holds the loading screen on the landing until the role hydrates, instead of flashing the dashboard", () => { + state.userRole = ""; + render(); + expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); + expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("does not hold the dashboard for an unhydrated role outside the post-login landing", () => { + state.login = null; + state.userRole = ""; + render(); + expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + }); + it("holds the loading screen while the key lookup is in flight", () => { state.keysLoading = true; render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 682ba0bbcb2..c43ba12985d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -93,10 +93,12 @@ function CreateKeyPageContent() { const isPostLoginLanding = searchParams.get("login") === "success"; const isSignedIn = !authLoading && Boolean(token); + const isAwaitingRole = isPostLoginLanding && isSignedIn && userRole === ""; const shouldCheckForKeys = isPostLoginLanding && isSignedIn && internalUserRoles.includes(userRole); const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys); const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0; const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding; + const isResolvingLanding = isAwaitingRole || isResolvingKeylessLanding; useEffect(() => { if (isKeylessLanding && !didReturnRedirectRef.current) { @@ -104,7 +106,7 @@ function CreateKeyPageContent() { } }, [isKeylessLanding, router]); - const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingKeylessLanding; + const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingLanding; if (authLoading || isRedirecting) { return ;