mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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.
This commit is contained in:
parent
301a02b7be
commit
f6fc6d299a
4 changed files with 176 additions and 0 deletions
65
ui/litellm-dashboard/src/app/connect/layout.test.tsx
Normal file
65
ui/litellm-dashboard/src/app/connect/layout.test.tsx
Normal file
|
|
@ -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: () => <div data-testid="navbar" /> }));
|
||||
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(
|
||||
<ConnectLayout>
|
||||
<div data-testid="page-content" />
|
||||
</ConnectLayout>,
|
||||
);
|
||||
expect(screen.getByTestId("navbar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when the user is not authorized", () => {
|
||||
state.isAuthorized = false;
|
||||
render(
|
||||
<ConnectLayout>
|
||||
<div data-testid="page-content" />
|
||||
</ConnectLayout>,
|
||||
);
|
||||
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(
|
||||
<ConnectLayout>
|
||||
<div data-testid="page-content" />
|
||||
</ConnectLayout>,
|
||||
);
|
||||
expect(screen.queryByTestId("page-content")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("navbar")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
20
ui/litellm-dashboard/src/app/connect/layout.tsx
Normal file
20
ui/litellm-dashboard/src/app/connect/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<Navbar accessToken={accessToken} isPublicPage={false} />
|
||||
<div className="min-h-0 flex-1 overflow-auto">{children}</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
55
ui/litellm-dashboard/src/app/connect/page.test.tsx
Normal file
55
ui/litellm-dashboard/src/app/connect/page.test.tsx
Normal file
|
|
@ -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) => <div data-testid="mcp-apps-panel" />),
|
||||
};
|
||||
});
|
||||
|
||||
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(<ConnectPage />);
|
||||
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(<ConnectPage />);
|
||||
expect(mockReplace).toHaveBeenCalledWith("/connect");
|
||||
});
|
||||
|
||||
it("does not rewrite the URL when there is no OAuth return param", () => {
|
||||
render(<ConnectPage />);
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
36
ui/litellm-dashboard/src/app/connect/page.tsx
Normal file
36
ui/litellm-dashboard/src/app/connect/page.tsx
Normal file
|
|
@ -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<string[]>([]);
|
||||
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 (
|
||||
<div className="mx-auto w-full max-w-5xl px-8 py-8">
|
||||
<MCPAppsPanel accessToken={accessToken ?? ""} selectedServers={selectedServers} onChange={setSelectedServers} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConnectPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ConnectPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue