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 ( + + + + ); +}