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",