feat(ui): connect-flow interlude banner on the MCP apps grid for gateway DCR sign-in

This commit is contained in:
Tin Chi Lo 2026-07-14 02:55:03 -07:00
parent 5df5fa50fa
commit 262e93ec27
3 changed files with 97 additions and 0 deletions

View file

@ -4,6 +4,7 @@ import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useChatShell } from "@/contexts/ChatShellContext";
import MCPAppsPanel from "@/components/chat/MCPAppsPanel";
import ConnectFlowBanner from "@/components/chat/ConnectFlowBanner";
// useSearchParams() requires a Suspense boundary for static export.
function IntegrationsPageContent() {
@ -11,6 +12,13 @@ function IntegrationsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const oauthReturn = searchParams.get("mcpOauthReturn");
// Set by the gateway DCR authorize when a DCR client sends the user here to
// authorize servers before finishing sign-in (see gateway_dcr_flow.py). The
// handle keys the sealed per-flow cookie; connect_client is the client origin
// for display only. connect_flow is NOT cleaned from the URL: the finish form
// needs it, and the sealed cookie (not the URL) is the security boundary.
const connectFlow = searchParams.get("connect_flow");
const connectClient = searchParams.get("connect_client");
// Clean up the OAuth return param after it's been consumed — real routing means
// we no longer need it to pick a tab, but it should not linger in the address bar.
@ -24,6 +32,7 @@ function IntegrationsPageContent() {
return (
<div className="flex-1 min-h-0 overflow-auto w-full py-8 px-8">
{connectFlow && <ConnectFlowBanner flowHandle={connectFlow} clientOrigin={connectClient} />}
<MCPAppsPanel accessToken={accessToken} selectedServers={selectedMCPServers} onChange={setSelectedMCPServers} />
</div>
);

View file

@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import ConnectFlowBanner from "./ConnectFlowBanner";
vi.mock("@/components/networking", () => ({
getProxyBaseUrl: () => "https://gateway.example.com",
}));
describe("ConnectFlowBanner", () => {
it("posts the flow handle to the proxy /authorize/complete as a full-page form", () => {
const { container } = render(<ConnectFlowBanner flowHandle="flow-handle-123" clientOrigin="https://claude.ai" />);
const form = container.querySelector("form")!;
expect(form.getAttribute("method")).toBe("POST");
expect(form.getAttribute("action")).toBe("https://gateway.example.com/authorize/complete");
const hidden = form.querySelector('input[name="flow"]') as HTMLInputElement;
expect(hidden.value).toBe("flow-handle-123");
// No token, code, or secret is ever placed in the form; the sealed cookie carries them.
expect(form.innerHTML).not.toContain("token");
});
it("shows the client origin so the user knows what they are connecting to", () => {
render(<ConnectFlowBanner flowHandle="h" clientOrigin="https://claude.ai" />);
expect(screen.getAllByText(/claude\.ai/).length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: /finish connecting/i })).toBeInTheDocument();
});
it("falls back to a generic label when the client origin is unknown", () => {
render(<ConnectFlowBanner flowHandle="h" clientOrigin={null} />);
expect(screen.getAllByText(/the application/).length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,55 @@
"use client";
import React from "react";
import { CheckCircle } from "lucide-react";
import { getProxyBaseUrl } from "@/components/networking";
interface Props {
flowHandle: string;
clientOrigin: string | null;
}
/**
* 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 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.
*/
const ConnectFlowBanner: React.FC<Props> = ({ flowHandle, clientOrigin }) => {
const action = `${getProxyBaseUrl()}/authorize/complete`;
const clientLabel = clientOrigin ?? "the application";
return (
<div className="mb-6 rounded-lg border border-primary/30 bg-primary/5 px-5 py-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex items-start gap-3 min-w-0">
<CheckCircle className="h-5 w-5 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">Connect your MCP servers to {clientLabel}</p>
<p className="text-[13px] text-muted-foreground mt-0.5">
Authorize the servers you want to use below. When you are ready, finish connecting and you will be
returned to {clientLabel}.
</p>
</div>
</div>
<form method="POST" action={action} className="shrink-0">
<input type="hidden" name="flow" value={flowHandle} />
<button
type="submit"
className="h-[38px] rounded-md bg-primary px-4 text-sm font-semibold text-primary-foreground hover:bg-primary/90"
>
Finish connecting
</button>
</form>
</div>
</div>
);
};
export default ConnectFlowBanner;