diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 9de171397db..14a3076c27e 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1712,7 +1712,7 @@ }, "src/components/chat/MCPAppsPanel.tsx": { "no-nested-ternary": { - "count": 7 + "count": 6 } }, "src/components/chat/MCPConnectPicker.tsx": { diff --git a/ui/litellm-dashboard/src/app/chat/integrations/page.tsx b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx index 452c7918bf6..30ce62d8081 100644 --- a/ui/litellm-dashboard/src/app/chat/integrations/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/integrations/page.tsx @@ -33,7 +33,12 @@ function IntegrationsPageContent() { return (
{connectFlow && } - +
); } diff --git a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx index 188ba00becb..9baf52966a6 100644 --- a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.test.tsx @@ -1,11 +1,17 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; import ConnectFlowBanner from "./ConnectFlowBanner"; +import { PERSERVER_CONNECTING_KEY } from "@/hooks/mcpOAuthUtils"; vi.mock("@/components/networking", () => ({ getProxyBaseUrl: () => "https://gateway.example.com", })); +afterEach(() => { + vi.restoreAllMocks(); + sessionStorage.clear(); +}); + describe("ConnectFlowBanner", () => { it("posts the flow handle to the proxy /authorize/complete as a full-page form", () => { const { container } = render(); @@ -30,4 +36,41 @@ describe("ConnectFlowBanner", () => { render(); expect(screen.getAllByText(/the application/).length).toBeGreaterThan(0); }); + + it("best-effort auto-finishes on pagehide (closing the tab)", () => { + const beaconMock = vi.fn(() => true); + vi.stubGlobal("navigator", { ...navigator, sendBeacon: beaconMock }); + render(); + + window.dispatchEvent(new Event("pagehide")); + + expect(beaconMock).toHaveBeenCalledTimes(1); + const [url, body] = beaconMock.mock.calls[0] as unknown as [string, URLSearchParams]; + expect(url).toBe("https://gateway.example.com/authorize/complete"); + expect(body.toString()).toContain("flow=flow-xyz"); + }); + + it("does NOT auto-finish while a per-server connect is navigating away", () => { + const beaconMock = vi.fn(() => true); + vi.stubGlobal("navigator", { ...navigator, sendBeacon: beaconMock }); + render(); + + // the per-server connect flow sets this right before it navigates to the upstream IdP + sessionStorage.setItem(PERSERVER_CONNECTING_KEY, "1"); + window.dispatchEvent(new Event("pagehide")); + + expect(beaconMock).not.toHaveBeenCalled(); + }); + + it("does NOT double-fire the auto-finish after the button was pressed", () => { + const beaconMock = vi.fn(() => true); + vi.stubGlobal("navigator", { ...navigator, sendBeacon: beaconMock }); + const { container } = render(); + + // jsdom does not submit forms; fire the form's submit so onSubmit marks it finished + fireEvent.submit(container.querySelector("form")!); + window.dispatchEvent(new Event("pagehide")); + + expect(beaconMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx index f4728b540c8..a46b4e59959 100644 --- a/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx +++ b/ui/litellm-dashboard/src/components/chat/ConnectFlowBanner.tsx @@ -1,8 +1,9 @@ "use client"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import { CheckCircle } from "lucide-react"; import { getProxyBaseUrl } from "@/components/networking"; +import { PERSERVER_CONNECTING_KEY } from "@/hooks/mcpOAuthUtils"; interface Props { flowHandle: string; @@ -13,17 +14,38 @@ interface Props { * The interlude shown when a DCR client (Claude Desktop, MCP Inspector) sends the user * through the gateway sign-in and lands them on the apps grid to authorize servers. The * grid below authorizes individual servers into the per-user vault; this banner is the - * deliberate finish step. + * finish step that returns the user to the client. * - * "Finish connecting" is a native form POST to the proxy's /authorize/complete, not a - * fetch: the endpoint 303-redirects the browser back to the DCR client's own redirect URI - * with the gateway authorization code, and only a full-page navigation carries the - * HttpOnly per-flow cookie and follows that cross-origin redirect. The flow handle is the - * only field; the sealed flow cookie set at /authorize holds everything else. + * Finishing happens two ways, both hitting the proxy's /authorize/complete, which mints the + * gateway authorization code and 303-redirects to the DCR client's own redirect URI: + * - The explicit "Finish connecting" button is a native form POST, so the full-page + * navigation carries the HttpOnly per-flow cookie and follows the cross-origin redirect + * to the client's loopback. This is the reliable path. + * - Closing (or navigating away from) the tab fires a best-effort navigator.sendBeacon to the + * same endpoint. The browser follows the 303 to the client's loopback, so in most browsers + * the code still reaches the client without an explicit click. This is a convenience, not a + * consent gate: consent already happened at sign-in, so returning the user is safe. It is + * skipped while a per-server connect is navigating away (that is not leaving the flow), + * and after the button was pressed (which already delivers the code). */ const ConnectFlowBanner: React.FC = ({ flowHandle, clientOrigin }) => { const action = `${getProxyBaseUrl()}/authorize/complete`; const clientLabel = clientOrigin ?? "the application"; + const finishedRef = useRef(false); + + useEffect(() => { + sessionStorage.removeItem(PERSERVER_CONNECTING_KEY); + + const autoFinishOnLeave = () => { + if (finishedRef.current) return; + if (sessionStorage.getItem(PERSERVER_CONNECTING_KEY) === "1") return; + if (typeof navigator.sendBeacon === "function") { + navigator.sendBeacon(action, new URLSearchParams({ flow: flowHandle })); + } + }; + window.addEventListener("pagehide", autoFinishOnLeave); + return () => window.removeEventListener("pagehide", autoFinishOnLeave); + }, [action, flowHandle]); return (
@@ -33,12 +55,12 @@ const ConnectFlowBanner: React.FC = ({ flowHandle, clientOrigin }) => {

Connect your MCP servers to {clientLabel}

- Authorize the servers you want to use below. When you are ready, finish connecting and you will be - returned to {clientLabel}. + Authorize the servers you want to use below, then finish connecting to return to {clientLabel}. Closing + this tab finishes for you.

-
+ (finishedRef.current = true)}>