From 5c343d0fb6bae5e95c5d8276470ba4a73d07732f Mon Sep 17 00:00:00 2001 From: ShaoLOWu <3034261766@qq.com> Date: Fri, 23 Oct 2026 18:36:25 +0800 Subject: [PATCH] fix(server-catalog): stay quiet when OAuth authorization is cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the OAuth popup without completing authorization is a deliberate user action, not a failure, so ServerCatalog no longer reports it. Previously both `handleAuthorize` (page-level notice above the grid) and `handleOAuthSubmit` (notice inside the Add-server dialog) passed the raw `OAuthCancelledError` message ("OAuth authorization was cancelled", untranslated) straight into their notification state. Both catch blocks now return early on `OAuthCancelledError` instead of notifying, mirroring the MCP Servers page (Servers.tsx), which already discards the cancellation silently. Genuine authorization failures keep surfacing their reason, and the dialog stays open for a retry in both cases. Tests: the cancelled-authorization test now uses `OAuthCancelledError` and asserts no cancellation notice appears anywhere (previously it rejected with a plain `Error` carrying the same text, covering cancel and failure identically); a new dialog test covers the real-failure path (provider reason still shown), and a new page-level test covers the Authorize menu action staying quiet on cancellation. Fixes IBM/mcp-context-forge#7303 (the catalog UI lives in this repo). Note: surfacing the provider's denial reason via `OAuthAuthorizationError` (the remaining suggestion in that issue) lands naturally after #199 adds the class to `src/api/servers.ts`; this PR intentionally does not duplicate it. Verification: `vitest run src/pages/ServerCatalog.test.tsx` 78/78 green; `tsc -b tsconfig.app.json` clean (after `orval` generation); eslint and prettier clean on both changed files. Assisted-by: AI (Claude via WorkBuddy) — diff reviewed and verified by the human contributor before submission. Signed-off-by: ShaoLOWu <3034261766@qq.com> --- src/pages/ServerCatalog.test.tsx | 87 ++++++++++++++++++++++++++++++-- src/pages/ServerCatalog.tsx | 11 +++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx index dada83b2..17eafa84 100644 --- a/src/pages/ServerCatalog.test.tsx +++ b/src/pages/ServerCatalog.test.tsx @@ -11,7 +11,7 @@ import { } from "@/api/catalog"; import { ApiError } from "@/api/client"; import { getOAuthStatuses } from "@/api/oauth"; -import { serversApi } from "@/api/servers"; +import { OAuthCancelledError, serversApi } from "@/api/servers"; import { TooltipProvider } from "@/components/ui/tooltip"; import type { CatalogListResponse, CatalogServer } from "@/generated/types"; import { useQuery } from "@/hooks/useQuery"; @@ -54,6 +54,14 @@ vi.mock("@/api/oauth", async (importOriginal) => ({ getOAuthStatuses: vi.fn(), })); vi.mock("@/api/servers", () => ({ + // Real error classes so `instanceof` checks in the page keep working + // against rejections created from the mocked module surface. + OAuthCancelledError: class OAuthCancelledError extends Error { + constructor(message = "OAuth authorization was cancelled") { + super(message); + this.name = "OAuthCancelledError"; + } + }, serversApi: { openOAuthAuthorizationPopup: vi.fn(), triggerOAuthAuthorization: vi.fn(), @@ -481,6 +489,46 @@ describe("ServerCatalog", () => { expect(mockGetOAuthStatuses).toHaveBeenCalledTimes(2); }); + it("stays quiet on the page when authorization is cancelled from the card actions", async () => { + const user = userEvent.setup(); + const registeredOAuthServer = { + ...oauthServer, + is_registered: true, + gateway_id: "gateway-github", + }; + mockUseQuery.mockReturnValue( + queryResult({ + data: { ...response, servers: [registeredOAuthServer], total: 1 }, + }), + ); + mockGetOAuthStatuses.mockResolvedValue({ + statuses: { + "gateway-github": { + oauth_enabled: true, + grant_type: "authorization_code", + user_token_status: { status: "missing", authorized: false }, + }, + }, + failures: {}, + }); + mockTriggerOAuthAuthorization.mockRejectedValue(new OAuthCancelledError()); + renderWithRouter(); + + expect(await screen.findByText("Needs authorization")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Actions for GitHub" })); + await user.click(screen.getByRole("menuitem", { name: "Authorize" })); + + await waitFor(() => + expect(mockTriggerOAuthAuthorization).toHaveBeenCalledWith("gateway-github"), + ); + await act(async () => {}); + // Closing the popup is deliberate: no page-level cancellation notice, + // and the success chain (enable toggle, status refetch) is skipped. + expect(screen.queryByText(/OAuth authorization was cancelled/)).not.toBeInTheDocument(); + expect(mockToggleEnabled).not.toHaveBeenCalled(); + expect(mockGetOAuthStatuses).toHaveBeenCalledTimes(1); + }); + it("refreshes caller OAuth status without gateway mutations when update is forbidden", async () => { const user = userEvent.setup(); const registeredOAuthServer = { @@ -525,7 +573,7 @@ describe("ServerCatalog", () => { it("keeps the OAuth form open after a cancelled authorization so it can retry", async () => { const user = userEvent.setup(); - mockTriggerOAuthAuthorization.mockRejectedValue(new Error("OAuth authorization was cancelled")); + mockTriggerOAuthAuthorization.mockRejectedValue(new OAuthCancelledError()); renderWithRouter(); await user.click(screen.getByRole("button", { name: "Add GitHub" })); @@ -544,7 +592,13 @@ describe("ServerCatalog", () => { ); await user.click(within(form).getByRole("button", { name: "Save" })); - expect(await within(form).findByText("OAuth authorization was cancelled")).toBeInTheDocument(); + await waitFor(() => expect(mockTriggerOAuthAuthorization).toHaveBeenCalledTimes(1)); + await act(async () => {}); + // Cancelling the popup is a deliberate user action: no cancellation + // notice anywhere, and the form stays open so the user can retry. + expect(within(form).queryByText("OAuth authorization was cancelled")).not.toBeInTheDocument(); + expect(screen.queryByText(/OAuth authorization was cancelled/)).not.toBeInTheDocument(); + expect(within(form).getByRole("button", { name: "Save" })).toBeInTheDocument(); expect(mockRegisterCatalogServer).toHaveBeenCalledTimes(1); mockTriggerOAuthAuthorization.mockResolvedValue({ type: "oauth_callback", status: "success" }); mockUseQuery.mockImplementation((path) => @@ -572,6 +626,33 @@ describe("ServerCatalog", () => { expect(screen.getByRole("button", { name: "Actions for GitHub" })).toHaveFocus(); }); + it("shows the provider's reason in the dialog when authorization fails", async () => { + const user = userEvent.setup(); + mockTriggerOAuthAuthorization.mockRejectedValue(new Error("Provider denied the request")); + renderWithRouter(); + + await user.click(screen.getByRole("button", { name: "Add GitHub" })); + const form = await screen.findByRole("form", { name: "Add GitHub" }); + await user.type(within(form).getByLabelText(/Issuer URL/i), "https://github.com"); + await user.type(within(form).getByLabelText(/^Scopes/i), "repo"); + await user.type(within(form).getByLabelText(/^Client ID/i), "github-client"); + await user.type(within(form).getByLabelText(/^Client Secret/i), "github-secret"); + await user.type( + within(form).getByLabelText(/^Authorization URL/i), + "https://github.com/login/oauth/authorize", + ); + await user.type( + within(form).getByLabelText(/^Token URL/i), + "https://github.com/login/oauth/access_token", + ); + await user.click(within(form).getByRole("button", { name: "Save" })); + + // Real failures still surface so the user can react; only the + // deliberate popup dismissal is silent. + expect(await within(form).findByText("Provider denied the request")).toBeVisible(); + expect(within(form).getByRole("button", { name: "Save" })).toBeInTheDocument(); + }); + it("preserves filters, restores focus on cancel, and clears credentials on reopening", async () => { const user = userEvent.setup(); const path = "/app/server-catalog?search=GitHub&provider=GitHub"; diff --git a/src/pages/ServerCatalog.tsx b/src/pages/ServerCatalog.tsx index d603cf36..6e8c4a22 100644 --- a/src/pages/ServerCatalog.tsx +++ b/src/pages/ServerCatalog.tsx @@ -10,7 +10,7 @@ import { type GatewayImpactPreview, } from "@/api/catalog"; import { ApiError } from "@/api/client"; -import { serversApi } from "@/api/servers"; +import { OAuthCancelledError, serversApi } from "@/api/servers"; import { useAuth } from "@/auth/useAuth"; import { CatalogApiKeyDialog } from "@/components/server-catalog/CatalogApiKeyDialog"; import { CatalogOAuthForm } from "@/components/server-catalog/CatalogOAuthForm"; @@ -765,6 +765,10 @@ export function ServerCatalog() { return true; } catch (error) { authWindow.close(); + // Closing the popup is a deliberate user action, not a failure: + // stay quiet so the form stays open for a retry (mirroring the MCP + // Servers page, which discards the cancellation silently). + if (error instanceof OAuthCancelledError) return false; setOAuthFormNotification({ id: `oauth:${oauthServer.id}`, type: "info", @@ -801,6 +805,11 @@ export function ServerCatalog() { setData((current) => setCatalogServerOAuthPending(current, server.id, false)); void refreshCatalogSilently(); } catch (error) { + // Closing the popup is a deliberate user action, not a failure: + // stay quiet instead of flashing a page-level notice for something + // that happened on one card (mirroring the MCP Servers page, which + // discards the cancellation silently). + if (error instanceof OAuthCancelledError) return; showRegistrationNotification({ id: `oauth:${server.id}`, type: "info",