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:
Tin Chi Lo 2026-07-22 17:49:14 -07:00
parent 301a02b7be
commit f6fc6d299a
4 changed files with 176 additions and 0 deletions

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

View 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>
);
}

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

View 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>
);
}