From 700cf159300ccd8309563748895136204a7c623b Mon Sep 17 00:00:00 2001 From: Vishu Bhatnagar Date: Tue, 8 Sep 2026 16:16:26 +0100 Subject: [PATCH 1/2] feat(catalog): add OAuth server setup flow Signed-off-by: Vishu Bhatnagar --- src/api/catalog.test.ts | 34 ++ src/api/catalog.ts | 33 +- .../server-catalog/CatalogOAuthDialog.tsx | 444 ++++++++++++++++++ .../server-catalog/CatalogResults.test.tsx | 41 ++ .../server-catalog/CatalogResults.tsx | 67 ++- src/i18n/locales/en-US/mcpServer.json | 15 + src/i18n/locales/es-ES/mcpServer.json | 15 + src/i18n/locales/pt-BR/mcpServer.json | 15 + src/pages/ServerCatalog.test.tsx | 141 +++++- src/pages/ServerCatalog.tsx | 222 ++++++++- 10 files changed, 992 insertions(+), 35 deletions(-) create mode 100644 src/components/server-catalog/CatalogOAuthDialog.tsx diff --git a/src/api/catalog.test.ts b/src/api/catalog.test.ts index e2ee7478..036a09b9 100644 --- a/src/api/catalog.test.ts +++ b/src/api/catalog.test.ts @@ -33,6 +33,40 @@ describe("registerCatalogServer", () => { }); }); + it("preserves OAuth credentials in the catalog registration body", async () => { + let body: unknown; + server.use( + http.post("*/api/v1/catalog/:catalogId/register", async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true, server_id: "gateway-1", message: "Registered" }); + }), + ); + + await registerCatalogServer("github", { + oauth_credentials: { + grant_type: "authorization_code", + issuer: "https://github.com", + client_id: "client-id", + client_secret: "client-secret", // pragma: allowlist secret + authorization_url: "https://github.com/login/oauth/authorize", + token_url: "https://github.com/login/oauth/access_token", + scopes: ["repo"], + }, + }); + + expect(body).toEqual({ + oauth_credentials: { + grant_type: "authorization_code", + issuer: "https://github.com", + client_id: "client-id", + client_secret: "client-secret", // pragma: allowlist secret + authorization_url: "https://github.com/login/oauth/authorize", + token_url: "https://github.com/login/oauth/access_token", + scopes: ["repo"], + }, + }); + }); + it("DELETEs an encoded gateway ID and preserves async lifecycle metadata", async () => { let requestPath = ""; server.use( diff --git a/src/api/catalog.ts b/src/api/catalog.ts index f97b9144..05f600ab 100644 --- a/src/api/catalog.ts +++ b/src/api/catalog.ts @@ -7,6 +7,37 @@ import type { GatewayTestResponse, } from "@/generated/types"; +/** Temporary handwritten contract until #6588 reaches generated OpenAPI types. */ +export interface CatalogOAuthCredentials { + grant_type: "authorization_code"; + issuer: string; + client_id: string; + client_secret: string; // pragma: allowlist secret + authorization_url: string; + token_url: string; + scopes: string[]; +} + +export type CatalogOAuthRegisterBody = CatalogServerRegisterBody & { + oauth_credentials: CatalogOAuthCredentials; +}; + +export interface OAuthUserTokenStatus { + status: "valid" | "near_expiry" | "expired" | "missing"; + authorized: boolean; + scopes?: string[]; + expires_at?: string | null; + updated_at?: string | null; +} + +export interface OAuthGatewayStatus { + oauth_enabled: boolean; + grant_type?: string; + user_token_status?: OAuthUserTokenStatus; +} + +export type OAuthGatewayStatusMap = Record; + export interface GatewayImpactPreview { gatewayId: string; servers: Array<{ id: string; name: string }>; @@ -17,7 +48,7 @@ export type CatalogGatewayDeleteResponse = GatewayRead | { status?: string; mess /** Register a catalog entry through the authenticated BFF proxy. */ export async function registerCatalogServer( catalogId: string, - body?: CatalogServerRegisterBody, + body?: CatalogServerRegisterBody | CatalogOAuthRegisterBody, ): Promise { return api.post( `/v1/catalog/${encodeURIComponent(catalogId)}/register`, diff --git a/src/components/server-catalog/CatalogOAuthDialog.tsx b/src/components/server-catalog/CatalogOAuthDialog.tsx new file mode 100644 index 00000000..37400305 --- /dev/null +++ b/src/components/server-catalog/CatalogOAuthDialog.tsx @@ -0,0 +1,444 @@ +import { useCallback, useMemo, useState } from "react"; +import { useIntl } from "react-intl"; + +import { TeamSelect } from "@/components/common/TeamSelect"; +import { VisibilityInfoPopover } from "@/components/common/VisibilityInfoPopover"; +import { CopyValue } from "@/components/ui/copy-value"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { InlineNotification } from "@/components/ui/inline-notification"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import type { CatalogOAuthRegisterBody } from "@/api/catalog"; +import type { CatalogServer } from "@/generated/types"; +import { useQuery } from "@/hooks/useQuery"; +import { useTeamScope } from "@/hooks/useTeams"; +import type { Visibility } from "@/types/server"; + +type OAuthField = + "issuer" | "scopes" | "clientId" | "clientSecret" | "authorizationUrl" | "tokenUrl" | "team"; +type FieldErrors = Partial>; + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } +} + +/** + * OAuth catalog registration deliberately has its own small field set. + * + * It shares catalog registration controls with the API-key dialog, but must + * not reuse the general server form's grant-type selector, password grant, or + * token-management controls. Catalog OAuth is always authorization-code. + */ +export function CatalogOAuthDialog({ + server, + onOpenChange, + onSubmit, + isSubmitting, + notification, + onDismissNotification, +}: { + server: CatalogServer; + onOpenChange: (open: boolean) => void; + onSubmit: (body: CatalogOAuthRegisterBody) => Promise; + isSubmitting: boolean; + notification?: { type: "success" | "error" | "info"; message: string }; + onDismissNotification?: () => void; +}) { + const intl = useIntl(); + const [name, setName] = useState(""); + const [issuer, setIssuer] = useState(""); + const [scopes, setScopes] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); // pragma: allowlist secret + const [authorizationUrl, setAuthorizationUrl] = useState(""); + const [tokenUrl, setTokenUrl] = useState(""); + const [visibility, setVisibility] = useState("private"); + const [teamId, setTeamId] = useState(""); + const [errors, setErrors] = useState({}); + const { data: callbackData } = useQuery<{ redirectUri: string }>("/oauth/callback-url", { + enabled: true, + }); + const { teams, onTeamChange } = useTeamScope({ + visibility, + teamId, + onTeamIdChange: setTeamId, + }); + + const callbackUrl = callbackData?.redirectUri; + const scopesList = useMemo( + () => + scopes + .split(/[\s,]+/) + .map((scope) => scope.trim()) + .filter(Boolean), + [scopes], + ); + + const reset = useCallback(() => { + setName(""); + setIssuer(""); + setScopes(""); + setClientId(""); + setClientSecret(""); + setAuthorizationUrl(""); + setTokenUrl(""); + setVisibility("private"); + setTeamId(""); + setErrors({}); + }, []); + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open && isSubmitting) return; + if (!open) reset(); + onOpenChange(open); + }, + [isSubmitting, onOpenChange, reset], + ); + + const handleSubmit = useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + const nextErrors: FieldErrors = { + ...(isHttpUrl(issuer.trim()) + ? {} + : { issuer: intl.formatMessage({ id: "mcpServer.catalog.oauth.issuerRequired" }) }), + ...(scopesList.length > 0 + ? {} + : { scopes: intl.formatMessage({ id: "mcpServer.catalog.oauth.scopesRequired" }) }), + ...(clientId.trim() + ? {} + : { clientId: intl.formatMessage({ id: "mcpServer.catalog.oauth.clientIdRequired" }) }), + ...(clientSecret.trim() + ? {} + : { + clientSecret: intl.formatMessage({ + id: "mcpServer.catalog.oauth.clientSecretRequired", + }), + }), + ...(isHttpUrl(authorizationUrl.trim()) + ? {} + : { + authorizationUrl: intl.formatMessage({ + id: "mcpServer.catalog.oauth.authorizationUrlRequired", + }), + }), + ...(isHttpUrl(tokenUrl.trim()) + ? {} + : { tokenUrl: intl.formatMessage({ id: "mcpServer.catalog.oauth.tokenUrlRequired" }) }), + ...(visibility !== "team" || teamId + ? {} + : { team: intl.formatMessage({ id: "mcpServer.catalog.apiKey.teamRequired" }) }), + }; + setErrors(nextErrors); + if (Object.keys(nextErrors).length > 0) return; + + const registered = await onSubmit({ + name: name.trim() || null, + visibility, + team_id: visibility === "team" ? teamId : null, + oauth_credentials: { + grant_type: "authorization_code", + issuer: issuer.trim(), + client_id: clientId.trim(), + client_secret: clientSecret, + authorization_url: authorizationUrl.trim(), + token_url: tokenUrl.trim(), + scopes: scopesList, + }, + }); + if (registered) handleOpenChange(false); + }, + [ + authorizationUrl, + clientId, + clientSecret, + handleOpenChange, + intl, + issuer, + name, + onSubmit, + scopesList, + teamId, + tokenUrl, + visibility, + ], + ); + + const required = ( + + ); + const fieldError = (field: OAuthField) => + errors[field] ?

{errors[field]}

: null; + + return ( + + +
void handleSubmit(event)}> + + + {intl.formatMessage({ id: "mcpServer.catalog.oauth.title" }, { name: server.name })} + + + {intl.formatMessage({ id: "mcpServer.catalog.oauth.description" })} + + + + {notification && ( +
+ +
+ )} + +
+
+ + setName(event.target.value)} + placeholder={intl.formatMessage({ id: "mcpServer.catalog.apiKey.namePlaceholder" })} + disabled={isSubmitting} + /> +
+ +
+ + { + setIssuer(event.target.value); + setErrors((current) => ({ ...current, issuer: undefined })); + }} + placeholder={intl.formatMessage({ + id: "mcpServer.auth.oauth.issuerUrlPlaceholder", + })} + aria-invalid={Boolean(errors.issuer)} + disabled={isSubmitting} + /> + {fieldError("issuer")} +
+ +
+ + { + setScopes(event.target.value); + setErrors((current) => ({ ...current, scopes: undefined })); + }} + placeholder={intl.formatMessage({ id: "mcpServer.auth.oauth.scopesPlaceholder" })} + aria-invalid={Boolean(errors.scopes)} + disabled={isSubmitting} + /> +

+ {intl.formatMessage({ id: "mcpServer.auth.oauth.scopesDescription" })} +

+ {fieldError("scopes")} +
+ + {callbackUrl && ( +
+ + +

+ {intl.formatMessage({ id: "mcpServer.auth.oauth.redirectUriHelp" })} +

+
+ )} + +
+ + { + setClientId(event.target.value); + setErrors((current) => ({ ...current, clientId: undefined })); + }} + placeholder={intl.formatMessage({ id: "mcpServer.auth.oauth.clientIdPlaceholder" })} + aria-invalid={Boolean(errors.clientId)} + disabled={isSubmitting} + /> + {fieldError("clientId")} +
+ +
+ + { + setClientSecret(event.target.value); + setErrors((current) => ({ ...current, clientSecret: undefined })); + }} + placeholder={intl.formatMessage({ + id: "mcpServer.auth.oauth.clientSecretPlaceholder", + })} + aria-invalid={Boolean(errors.clientSecret)} + disabled={isSubmitting} + /> + {fieldError("clientSecret")} +
+ +
+ + { + setAuthorizationUrl(event.target.value); + setErrors((current) => ({ ...current, authorizationUrl: undefined })); + }} + placeholder={intl.formatMessage({ + id: "mcpServer.auth.oauth.authorizationUrlPlaceholder", + })} + aria-invalid={Boolean(errors.authorizationUrl)} + disabled={isSubmitting} + /> + {fieldError("authorizationUrl")} +
+ +
+ + { + setTokenUrl(event.target.value); + setErrors((current) => ({ ...current, tokenUrl: undefined })); + }} + placeholder={intl.formatMessage({ id: "mcpServer.auth.oauth.tokenUrlPlaceholder" })} + aria-invalid={Boolean(errors.tokenUrl)} + disabled={isSubmitting} + /> + {fieldError("tokenUrl")} +
+ +
+
+ + +
+ +
+ {visibility === "team" && ( + + )} +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/server-catalog/CatalogResults.test.tsx b/src/components/server-catalog/CatalogResults.test.tsx index 5adeb0ca..83a331bc 100644 --- a/src/components/server-catalog/CatalogResults.test.tsx +++ b/src/components/server-catalog/CatalogResults.test.tsx @@ -32,6 +32,7 @@ function catalogResults( onAdd={vi.fn()} addingServerIds={addingServerIds} onTest={vi.fn()} + onAuthorize={vi.fn()} onDisconnect={vi.fn()} testingServerIds={testingServerIds} disconnectingServerIds={disconnectingServerIds} @@ -90,6 +91,46 @@ describe("CatalogResults", () => { expect(screen.queryByText("Connected")).not.toBeInTheDocument(); }); + it("shows caller-scoped expired OAuth state and offers authorization retry", async () => { + const user = userEvent.setup(); + const onAuthorize = vi.fn(); + const oauthServer: CatalogServer = { + ...availableServer, + id: "github", + name: "GitHub", + auth_type: "OAuth2.1", + is_registered: true, + gateway_id: "gateway-github", + }; + + renderWithProviders( + , + ); + + expect(screen.getByText("Authorization expired")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Actions for GitHub" })); + await user.click(screen.getByRole("menuitem", { name: "Authorize" })); + + expect(onAuthorize).toHaveBeenCalledOnce(); + }); + it("routes bundled catalog logos through the BFF", () => { const { container } = renderWithProviders( catalogResults({ ...availableServer, logo_url: "/static/catalog-icons/asana.png" }), diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx index 3949a8bd..2fd2f567 100644 --- a/src/components/server-catalog/CatalogResults.tsx +++ b/src/components/server-catalog/CatalogResults.tsx @@ -2,7 +2,8 @@ import { useEffect, useId, useRef } from "react"; import type { ReactNode } from "react"; import { EllipsisVertical, FileText, Plus } from "lucide-react"; import { useIntl } from "react-intl"; -import { STATUS_ICON } from "@/lib/status"; +import { STATUS_ICON, STATUS_TONE_CLASS } from "@/lib/status"; +import type { OAuthGatewayStatus } from "@/api/catalog"; import { EmptyStatePlaceholder } from "@/components/dashboard/EmptyStatePlaceholder"; import { CatalogLogo } from "@/components/server-catalog/CatalogLogo"; @@ -27,29 +28,67 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { getTagLabels } from "@/utils/tags"; const EMPTY_PENDING_IDS: ReadonlySet = new Set(); +const EMPTY_OAUTH_STATUSES: Readonly> = {}; + +function getOAuthCardState(server: CatalogServer, status?: OAuthGatewayStatus) { + const tokenState = status?.user_token_status?.status; + if (tokenState === "valid") + return { + messageId: "mcpServer.catalog.connected", + severity: "success" as const, + canAuthorize: false, + }; + if (tokenState === "near_expiry") + return { + messageId: "mcpServer.catalog.oauth.nearExpiry", + severity: "warning" as const, + canAuthorize: false, + }; + if (tokenState === "expired") + return { + messageId: "mcpServer.catalog.oauth.expired", + severity: "error" as const, + canAuthorize: true, + }; + if (server.requires_oauth_config || tokenState === "missing") + return { + messageId: "mcpServer.catalog.oauth.needsAuthorization", + severity: "info" as const, + canAuthorize: true, + }; + return { + messageId: "mcpServer.catalog.connected", + severity: "success" as const, + canAuthorize: false, + }; +} function CatalogCard({ server, onView, onAdd, onTest, + onAuthorize, onDisconnect, isAdding, isTesting, isDisconnecting, canTest, canDisconnect, + oauthStatuses, }: { server: CatalogServer; onView: (trigger: HTMLElement) => void; onAdd: () => void; onTest: () => void; + onAuthorize: () => void; onDisconnect: () => void; isAdding: boolean; isTesting: boolean; isDisconnecting: boolean; canTest: boolean; canDisconnect: boolean; + oauthStatuses?: Readonly>; }) { const intl = useIntl(); const headingId = useId(); @@ -57,6 +96,11 @@ function CatalogCard({ const actionsTriggerRef = useRef(null); const pendingDetailsTriggerRef = useRef(null); const shouldTransferAddFocusRef = useRef(false); + const oauthState = getOAuthCardState( + server, + server.gateway_id ? oauthStatuses?.[server.gateway_id] : undefined, + ); + const StatusIcon = STATUS_ICON[oauthState.severity]; useEffect(() => { if (server.is_registered && shouldTransferAddFocusRef.current) { @@ -99,8 +143,11 @@ function CatalogCard({ ) : ( - )} @@ -150,6 +197,14 @@ function CatalogCard({ {intl.formatMessage({ id: "mcpServer.catalog.test" })} )} + {oauthState.canAuthorize && server.gateway_id && ( + + {intl.formatMessage({ id: "mcpServer.catalog.oauth.authorize" })} + + )} {canDisconnect && server.gateway_id && ( void; addingServerIds: ReadonlySet; onTest: (server: CatalogServer) => void; + onAuthorize: (server: CatalogServer) => void; onDisconnect: (server: CatalogServer) => void; testingServerIds?: ReadonlySet; disconnectingServerIds?: ReadonlySet; canTest: boolean; canDisconnect: boolean; + oauthStatuses?: Readonly>; }) { const intl = useIntl(); const announcedCount = useDebouncedValue(servers.length, 300); @@ -330,12 +389,14 @@ export function CatalogResults({ onView={(trigger) => onView(server, trigger)} onAdd={() => onAdd(server)} onTest={() => onTest(server)} + onAuthorize={() => onAuthorize(server)} onDisconnect={() => onDisconnect(server)} isAdding={addingServerIds.has(server.id)} isTesting={testingServerIds.has(server.id)} isDisconnecting={disconnectingServerIds.has(server.id)} canTest={canTest} canDisconnect={canDisconnect} + oauthStatuses={oauthStatuses} /> ))} diff --git a/src/i18n/locales/en-US/mcpServer.json b/src/i18n/locales/en-US/mcpServer.json index 43b7ca10..845cdc8c 100644 --- a/src/i18n/locales/en-US/mcpServer.json +++ b/src/i18n/locales/en-US/mcpServer.json @@ -113,6 +113,21 @@ "mcpServer.catalog.apiKey.required": "API key is required.", "mcpServer.catalog.apiKey.teamRequired": "Select a team.", "mcpServer.catalog.apiKey.submit": "Add server", + "mcpServer.catalog.oauth.title": "Add {name}", + "mcpServer.catalog.oauth.description": "Configure OAuth and choose who can use this server.", + "mcpServer.catalog.oauth.submit": "Configure and authorize", + "mcpServer.catalog.oauth.issuerRequired": "Enter a valid issuer URL.", + "mcpServer.catalog.oauth.scopesRequired": "Enter at least one scope.", + "mcpServer.catalog.oauth.clientIdRequired": "Client ID is required.", + "mcpServer.catalog.oauth.clientSecretRequired": "Client secret is required.", + "mcpServer.catalog.oauth.authorizationUrlRequired": "Enter a valid authorization URL.", + "mcpServer.catalog.oauth.tokenUrlRequired": "Enter a valid token URL.", + "mcpServer.catalog.oauth.needsAuthorization": "Needs authorization", + "mcpServer.catalog.oauth.nearExpiry": "Authorization expires soon", + "mcpServer.catalog.oauth.expired": "Authorization expired", + "mcpServer.catalog.oauth.authorize": "Authorize", + "mcpServer.catalog.oauth.authorizationError": "OAuth authorization could not be completed. Try again.", + "mcpServer.catalog.oauth.authorizedToolsPending": "{name} is authorized, but tools could not be fetched. Try again from the server actions.", "mcpServer.catalog.addError": "Unable to add this server. Try again.", "mcpServer.catalog.addNotFound": "{name} is no longer available in the catalog.", "mcpServer.catalog.alreadyConnected": "{name} is already connected.", diff --git a/src/i18n/locales/es-ES/mcpServer.json b/src/i18n/locales/es-ES/mcpServer.json index 031eae99..7ac40fdc 100644 --- a/src/i18n/locales/es-ES/mcpServer.json +++ b/src/i18n/locales/es-ES/mcpServer.json @@ -113,6 +113,21 @@ "mcpServer.catalog.apiKey.required": "La clave API es obligatoria.", "mcpServer.catalog.apiKey.teamRequired": "Selecciona un equipo.", "mcpServer.catalog.apiKey.submit": "Añadir servidor", + "mcpServer.catalog.oauth.title": "Añadir {name}", + "mcpServer.catalog.oauth.description": "Configura OAuth y elige quién puede usar este servidor.", + "mcpServer.catalog.oauth.submit": "Configurar y autorizar", + "mcpServer.catalog.oauth.issuerRequired": "Introduce una URL de emisor válida.", + "mcpServer.catalog.oauth.scopesRequired": "Introduce al menos un ámbito.", + "mcpServer.catalog.oauth.clientIdRequired": "El ID de cliente es obligatorio.", + "mcpServer.catalog.oauth.clientSecretRequired": "El secreto de cliente es obligatorio.", + "mcpServer.catalog.oauth.authorizationUrlRequired": "Introduce una URL de autorización válida.", + "mcpServer.catalog.oauth.tokenUrlRequired": "Introduce una URL de token válida.", + "mcpServer.catalog.oauth.needsAuthorization": "Necesita autorización", + "mcpServer.catalog.oauth.nearExpiry": "La autorización caduca pronto", + "mcpServer.catalog.oauth.expired": "La autorización caducó", + "mcpServer.catalog.oauth.authorize": "Autorizar", + "mcpServer.catalog.oauth.authorizationError": "No se pudo completar la autorización OAuth. Inténtalo de nuevo.", + "mcpServer.catalog.oauth.authorizedToolsPending": "{name} está autorizado, pero no se pudieron obtener las herramientas. Inténtalo de nuevo desde las acciones del servidor.", "mcpServer.catalog.addError": "No se pudo añadir este servidor. Inténtalo de nuevo.", "mcpServer.catalog.addNotFound": "{name} ya no está disponible en el catálogo.", "mcpServer.catalog.alreadyConnected": "{name} ya está conectado.", diff --git a/src/i18n/locales/pt-BR/mcpServer.json b/src/i18n/locales/pt-BR/mcpServer.json index 6a96be4c..8f95a39f 100644 --- a/src/i18n/locales/pt-BR/mcpServer.json +++ b/src/i18n/locales/pt-BR/mcpServer.json @@ -113,6 +113,21 @@ "mcpServer.catalog.apiKey.required": "A chave de API é obrigatória.", "mcpServer.catalog.apiKey.teamRequired": "Selecione uma equipe.", "mcpServer.catalog.apiKey.submit": "Adicionar servidor", + "mcpServer.catalog.oauth.title": "Adicionar {name}", + "mcpServer.catalog.oauth.description": "Configure OAuth e escolha quem pode usar este servidor.", + "mcpServer.catalog.oauth.submit": "Configurar e autorizar", + "mcpServer.catalog.oauth.issuerRequired": "Informe uma URL de emissor válida.", + "mcpServer.catalog.oauth.scopesRequired": "Informe pelo menos um escopo.", + "mcpServer.catalog.oauth.clientIdRequired": "ID do cliente obrigatório.", + "mcpServer.catalog.oauth.clientSecretRequired": "Segredo do cliente obrigatório.", + "mcpServer.catalog.oauth.authorizationUrlRequired": "Informe uma URL de autorização válida.", + "mcpServer.catalog.oauth.tokenUrlRequired": "Informe uma URL de token válida.", + "mcpServer.catalog.oauth.needsAuthorization": "Precisa de autorização", + "mcpServer.catalog.oauth.nearExpiry": "A autorização expira em breve", + "mcpServer.catalog.oauth.expired": "A autorização expirou", + "mcpServer.catalog.oauth.authorize": "Autorizar", + "mcpServer.catalog.oauth.authorizationError": "Não foi possível concluir a autorização OAuth. Tente novamente.", + "mcpServer.catalog.oauth.authorizedToolsPending": "{name} está autorizado, mas não foi possível buscar as ferramentas. Tente novamente pelas ações do servidor.", "mcpServer.catalog.addError": "Não foi possível adicionar este servidor. Tente novamente.", "mcpServer.catalog.addNotFound": "{name} não está mais disponível no catálogo.", "mcpServer.catalog.alreadyConnected": "{name} já está conectado.", diff --git a/src/pages/ServerCatalog.test.tsx b/src/pages/ServerCatalog.test.tsx index 3e04065e..9bd196e1 100644 --- a/src/pages/ServerCatalog.test.tsx +++ b/src/pages/ServerCatalog.test.tsx @@ -10,6 +10,7 @@ import { testCatalogServer, } from "@/api/catalog"; import { ApiError } from "@/api/client"; +import { serversApi } from "@/api/servers"; import type { CatalogListResponse, CatalogServer } from "@/generated/types"; import { useQuery } from "@/hooks/useQuery"; import { I18nProvider } from "@/i18n"; @@ -46,12 +47,22 @@ vi.mock("@/api/catalog", () => ({ getGatewayImpactPreview: vi.fn(), testCatalogServer: vi.fn(), })); +vi.mock("@/api/servers", () => ({ + serversApi: { + triggerOAuthAuthorization: vi.fn(), + toggleEnabled: vi.fn(), + fetchToolsAfterOAuth: vi.fn(), + }, +})); const mockUseQuery = vi.mocked(useQuery); const mockRegisterCatalogServer = vi.mocked(registerCatalogServer); const mockDisconnectCatalogGateway = vi.mocked(disconnectCatalogGateway); const mockGetGatewayImpactPreview = vi.mocked(getGatewayImpactPreview); const mockTestCatalogServer = vi.mocked(testCatalogServer); +const mockTriggerOAuthAuthorization = vi.mocked(serversApi.triggerOAuthAuthorization); +const mockToggleEnabled = vi.mocked(serversApi.toggleEnabled); +const mockFetchToolsAfterOAuth = vi.mocked(serversApi.fetchToolsAfterOAuth); const openConnected: CatalogServer = { id: "open-connected", @@ -91,13 +102,25 @@ const apiKeyServer: CatalogServer = { is_registered: false, }; +const oauthServer: CatalogServer = { + id: "oauth", + name: "GitHub", + category: "Developer Tools", + url: "https://api.githubcopilot.com/mcp/", + auth_type: "OAuth2.1", + provider: "GitHub", + description: "Requires user OAuth authorization", + tags: ["developer-tools"], + is_registered: false, +}; + const response: CatalogListResponse = { - servers: [openConnected, openAvailable, apiKeyServer], - total: 3, - categories: ["Monitoring", "Productivity", "Security"], - auth_types: ["API Key", "Open"], - providers: ["Example", "jsDelivr", "SecureCo"], - all_tags: ["documents", "network", "observability", "search", "security"], + servers: [openConnected, openAvailable, apiKeyServer, oauthServer], + total: 4, + categories: ["Developer Tools", "Monitoring", "Productivity", "Security"], + auth_types: ["API Key", "OAuth2.1", "Open"], + providers: ["Example", "GitHub", "jsDelivr", "SecureCo"], + all_tags: ["developer-tools", "documents", "network", "observability", "search", "security"], }; function queryResult(overrides: Partial> = {}) { @@ -149,6 +172,7 @@ describe("ServerCatalog", () => { beforeEach(() => { window.history.replaceState({}, "", "/app/"); mockUseQuery.mockReturnValue(queryResult()); + mockRegisterCatalogServer.mockReset(); mockRegisterCatalogServer.mockResolvedValue({ success: true, server_id: "registered-server", @@ -165,6 +189,12 @@ describe("ServerCatalog", () => { }); mockGetGatewayImpactPreview.mockResolvedValue({ gatewayId: "gateway-globalping", servers: [] }); mockTestCatalogServer.mockResolvedValue({ statusCode: 200, latencyMs: 12 }); + mockTriggerOAuthAuthorization.mockReset(); + mockToggleEnabled.mockReset(); + mockFetchToolsAfterOAuth.mockReset(); + mockTriggerOAuthAuthorization.mockResolvedValue({ type: "oauth_callback", status: "success" }); + mockToggleEnabled.mockResolvedValue({ status: "success", message: "Activated" }); + mockFetchToolsAfterOAuth.mockResolvedValue({ success: true, message: "Tools fetched" }); }); it("uses the catalog GET endpoint and shared loader", () => { @@ -176,6 +206,24 @@ describe("ServerCatalog", () => { expect(screen.getByRole("status", { name: "Loading..." })).toBeInTheDocument(); }); + it("loads caller-scoped OAuth status in one batch for registered OAuth cards", () => { + mockUseQuery.mockReturnValue( + queryResult({ + data: { + ...response, + servers: [{ ...oauthServer, is_registered: true, gateway_id: "gateway-github" }], + total: 1, + }, + }), + ); + + renderWithRouter(); + + expect(mockUseQuery).toHaveBeenCalledWith("/oauth/status?gateway_ids=gateway-github", { + enabled: true, + }); + }); + it("keeps cached catalog data visible during refreshes and refresh failures", () => { let currentQueryResult = queryResult({ isLoading: true }); mockUseQuery.mockImplementation(() => currentQueryResult); @@ -191,25 +239,98 @@ describe("ServerCatalog", () => { expect(screen.queryByText("Unable to load server catalog. Try again.")).not.toBeInTheDocument(); }); - it("renders supported Open and API-key entries and marks registered servers connected", () => { + it("renders Open, API-key, and OAuth entries and marks registered servers connected", () => { renderWithRouter(); expect(screen.getByRole("region", { name: "Server catalog" })).toBeInTheDocument(); const catalogList = screen.getByRole("list", { name: "Catalog servers" }); - expect(within(catalogList).getAllByRole("listitem")).toHaveLength(3); + expect(within(catalogList).getAllByRole("listitem")).toHaveLength(4); expect(screen.getByRole("heading", { name: "Globalping" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Public Notes" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Secret Service" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "GitHub" })).toBeInTheDocument(); expect(within(catalogList).getByText("Connected")).toBeInTheDocument(); - expect(screen.getByRole("status")).toHaveTextContent("3 servers shown"); + expect(screen.getByRole("status")).toHaveTextContent("4 servers shown"); expect(screen.getByRole("button", { name: "Actions for Globalping" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add Public Notes" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Add Secret Service" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add GitHub" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "View Globalping" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "View Public Notes" })).toBeInTheDocument(); expect(screen.queryByText(/registration coming soon/i)).not.toBeInTheDocument(); }); + it("collects OAuth credentials in the catalog dialog and registers them in one call", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await user.click(screen.getByRole("button", { name: "Add GitHub" })); + const dialog = await screen.findByRole("dialog", { name: "Add GitHub" }); + + await user.type(within(dialog).getByLabelText(/Issuer URL/i), "https://github.com"); + await user.type(within(dialog).getByLabelText(/^Scopes/i), "repo read:user"); + await user.type(within(dialog).getByLabelText(/^Client ID/i), "github-client"); + await user.type(within(dialog).getByLabelText(/^Client Secret/i), "github-secret"); + await user.type( + within(dialog).getByLabelText(/^Authorization URL/i), + "https://github.com/login/oauth/authorize", + ); + await user.type( + within(dialog).getByLabelText(/^Token URL/i), + "https://github.com/login/oauth/access_token", + ); + await user.click(within(dialog).getByRole("button", { name: "Configure and authorize" })); + + await waitFor(() => + expect(mockRegisterCatalogServer).toHaveBeenCalledWith("oauth", { + name: null, + visibility: "private", + team_id: null, + oauth_credentials: { + grant_type: "authorization_code", + issuer: "https://github.com", + client_id: "github-client", + client_secret: "github-secret", + authorization_url: "https://github.com/login/oauth/authorize", + token_url: "https://github.com/login/oauth/access_token", + scopes: ["repo", "read:user"], + }, + }), + ); + await waitFor(() => + expect(mockTriggerOAuthAuthorization).toHaveBeenCalledWith("registered-server"), + ); + expect(mockToggleEnabled).toHaveBeenCalledWith("registered-server", true); + expect(mockFetchToolsAfterOAuth).toHaveBeenCalledWith("registered-server"); + }); + + it("keeps the OAuth dialog open after a cancelled authorization so it can retry", async () => { + const user = userEvent.setup(); + mockTriggerOAuthAuthorization.mockRejectedValue(new Error("OAuth authorization was cancelled")); + renderWithRouter(); + + await user.click(screen.getByRole("button", { name: "Add GitHub" })); + const dialog = await screen.findByRole("dialog", { name: "Add GitHub" }); + await user.type(within(dialog).getByLabelText(/Issuer URL/i), "https://github.com"); + await user.type(within(dialog).getByLabelText(/^Scopes/i), "repo"); + await user.type(within(dialog).getByLabelText(/^Client ID/i), "github-client"); + await user.type(within(dialog).getByLabelText(/^Client Secret/i), "github-secret"); + await user.type( + within(dialog).getByLabelText(/^Authorization URL/i), + "https://github.com/login/oauth/authorize", + ); + await user.type( + within(dialog).getByLabelText(/^Token URL/i), + "https://github.com/login/oauth/access_token", + ); + await user.click(within(dialog).getByRole("button", { name: "Configure and authorize" })); + + expect( + await within(dialog).findByText("OAuth authorization was cancelled"), + ).toBeInTheDocument(); + expect(mockRegisterCatalogServer).toHaveBeenCalledTimes(1); + }); + it("offers API auth entries through the API-key add flow", async () => { const user = userEvent.setup(); mockUseQuery.mockReturnValue( @@ -784,7 +905,7 @@ describe("ServerCatalog", () => { current: CatalogListResponse | undefined, ) => CatalogListResponse | undefined; expect(updateCatalog(response)?.servers).not.toContainEqual(openAvailable); - expect(updateCatalog(response)?.total).toBe(2); + expect(updateCatalog(response)?.total).toBe(3); expect(refetch).toHaveBeenCalledOnce(); expect(screen.queryByText("Catalog server not found")).not.toBeInTheDocument(); diff --git a/src/pages/ServerCatalog.tsx b/src/pages/ServerCatalog.tsx index f8c0f982..59e962dc 100644 --- a/src/pages/ServerCatalog.tsx +++ b/src/pages/ServerCatalog.tsx @@ -8,10 +8,14 @@ import { registerCatalogServer, testCatalogServer, type GatewayImpactPreview, + type CatalogOAuthRegisterBody, + type OAuthGatewayStatusMap, } from "@/api/catalog"; import { ApiError } from "@/api/client"; +import { serversApi } from "@/api/servers"; import { useAuth } from "@/auth/useAuth"; import { CatalogApiKeyDialog } from "@/components/server-catalog/CatalogApiKeyDialog"; +import { CatalogOAuthDialog } from "@/components/server-catalog/CatalogOAuthDialog"; import { CatalogResults, CatalogServerDetailsDialog, @@ -40,8 +44,8 @@ const CATALOG_PATH = "/v1/catalog?limit=1000"; const PAGE_PATH = "/app/server-catalog"; const OPEN_AUTH_TYPE = "Open"; const API_KEY_AUTH_TYPES = new Set(["API Key", "API"]); -const SUPPORTED_AUTH_TYPES = [OPEN_AUTH_TYPE, ...API_KEY_AUTH_TYPES]; -const SUPPORTED_AUTH_TYPE_SET = new Set(SUPPORTED_AUTH_TYPES); +const OAUTH_AUTH_TYPES = new Set(["OAuth", "OAuth2.1", "OAuth2.1 & API Key"]); +const CATALOG_AUTH_TYPES = [OPEN_AUTH_TYPE, ...API_KEY_AUTH_TYPES, ...OAUTH_AUTH_TYPES]; const PAGE_HEADING_ID = "server-catalog-heading"; interface CatalogFilters { @@ -60,6 +64,11 @@ interface RegistrationNotification { retryCatalogServer?: CatalogServer; } +interface RegistrationResult { + success: boolean; + gatewayId?: string; +} + type ImpactPreviewStatus = "idle" | "loading" | "loaded" | "error"; const DISCONNECT_POLL_TIMEOUT_MS = 30_000; @@ -149,17 +158,13 @@ function useCatalogFilters() { return { filters, updateQuery, toggleFilterOption, clearFilterSection, clearAllFilters }; } -function getSupportedServers(servers: CatalogServer[]): CatalogServer[] { - return servers.filter((server) => SUPPORTED_AUTH_TYPE_SET.has(server.auth_type)); -} - function filterSupportedServers( - supportedServers: CatalogServer[], + catalogServers: CatalogServer[], filters: CatalogFilters, ): CatalogServer[] { const search = filters.search.trim().toLocaleLowerCase(); - return supportedServers.filter((server) => { + return catalogServers.filter((server) => { if (filters.category.length > 0 && !filters.category.includes(server.category ?? "")) { return false; } @@ -184,6 +189,20 @@ function sortedUnique(values: Array): string[] { return [...new Set(values.filter((value): value is string => Boolean(value)))].sort(); } +function isOAuthServer(server: CatalogServer): boolean { + return OAUTH_AUTH_TYPES.has(server.auth_type); +} + +function getOAuthStatusesPath(servers: CatalogServer[]): string | null { + const gatewayIds = servers + .filter((server) => server.is_registered && server.gateway_id && isOAuthServer(server)) + .map((server) => server.gateway_id!); + if (gatewayIds.length === 0) return null; + const params = new URLSearchParams(); + gatewayIds.forEach((gatewayId) => params.append("gateway_ids", gatewayId)); + return `/oauth/status?${params.toString()}`; +} + function setCatalogServerRegistration( catalog: CatalogListResponse | undefined, serverId: string, @@ -209,6 +228,20 @@ function setCatalogServerRegistration( return { ...catalog, servers }; } +function setCatalogServerOAuthPending( + catalog: CatalogListResponse | undefined, + serverId: string, + requiresOAuthConfig: boolean, +): CatalogListResponse | undefined { + if (!catalog) return catalog; + const serverIndex = catalog.servers.findIndex((server) => server.id === serverId); + if (serverIndex === -1) return catalog; + + const servers = [...catalog.servers]; + servers[serverIndex] = { ...servers[serverIndex], requires_oauth_config: requiresOAuthConfig }; + return { ...catalog, servers }; +} + function getRetryAfterMs(value: string | null): number { const seconds = Number(value); const dateDelay = value ? Date.parse(value) - Date.now() : Number.NaN; @@ -334,6 +367,12 @@ export function ServerCatalog() { const impactRequestIdRef = useRef(0); const disconnectPollAbortControllersRef = useRef(new Map()); const [apiKeyServer, setApiKeyServer] = useState(null); + const [oauthServer, setOAuthServer] = useState(null); + const [oauthDialogNotification, setOAuthDialogNotification] = useState< + RegistrationNotification | undefined + >(); + const [oauthAuthorizing, setOAuthAuthorizing] = useState(false); + const [pendingOAuthGatewayId, setPendingOAuthGatewayId] = useState(null); const [focusActionsForServerId, setFocusActionsForServerId] = useState(null); const lastViewTriggerRef = useRef(null); const pageHeadingRef = useRef(null); @@ -341,6 +380,13 @@ export function ServerCatalog() { const notificationToFocusRef = useRef(null); const shouldRedirectDisconnectCloseFocusRef = useRef(false); const { data, error, isLoading, refetch, setData } = useQuery(CATALOG_PATH); + const oauthStatusesPath = useMemo( + () => getOAuthStatusesPath(data?.servers ?? []), + [data?.servers], + ); + const { data: oauthStatuses } = useQuery(oauthStatusesPath, { + enabled: oauthStatusesPath !== null, + }); const canTest = !permissionsLoading && hasPermission("gateways.read"); const canDisconnect = !permissionsLoading && hasPermission("gateways.delete"); const { filters, updateQuery, toggleFilterOption, clearFilterSection, clearAllFilters } = @@ -405,7 +451,7 @@ export function ServerCatalog() { // URL the moment they are ticked in the filters popover. const activeFilters = useMemo(() => ({ ...filters, search }), [filters, search]); - const supportedServers = useMemo(() => getSupportedServers(data?.servers ?? []), [data?.servers]); + const supportedServers = useMemo(() => data?.servers ?? [], [data?.servers]); const servers = useMemo( () => filterSupportedServers(supportedServers, activeFilters), [supportedServers, activeFilters], @@ -422,7 +468,11 @@ export function ServerCatalog() { () => sortedUnique(supportedServers.flatMap((server) => getTagLabels(server.tags ?? []))), [supportedServers], ); - const authTypeOptions = SUPPORTED_AUTH_TYPES; + const authTypeOptions = useMemo( + () => + sortedUnique([...CATALOG_AUTH_TYPES, ...supportedServers.map((server) => server.auth_type)]), + [supportedServers], + ); const hasSupportedServers = supportedServers.length > 0; const hasConnectedServers = supportedServers.some((server) => server.is_registered); const emptyStateMessageId = !hasSupportedServers @@ -453,13 +503,13 @@ export function ServerCatalog() { const registerServer = useCallback( async ( server: CatalogServer, - body?: CatalogServerRegisterBody, + body?: CatalogServerRegisterBody | CatalogOAuthRegisterBody, reportNotification: ( notification: RegistrationNotification, shouldFocus?: boolean, ) => void = showRegistrationNotification, - ): Promise => { - if (!beginAdding(server.id)) return false; + ): Promise => { + if (!beginAdding(server.id)) return { success: false }; dismissRegistrationNotification(`add:${server.id}`); try { const result = body @@ -471,14 +521,14 @@ export function ServerCatalog() { type: "error", message: result.message || intl.formatMessage({ id: "mcpServer.catalog.addError" }), }); - return false; + return { success: false }; } setData((current) => setCatalogServerRegistration(current, server.id, true, result.server_id), ); void refreshCatalogSilently(); - return true; + return { success: true, gatewayId: result.server_id }; } catch (registrationError) { if (registrationError instanceof ApiError && registrationError.status === 409) { // Terminal outcome, not a retryable error: always surface on the grid (matching the @@ -493,7 +543,7 @@ export function ServerCatalog() { ), }); await refreshCatalogSilently(); - return true; + return { success: true }; } if (registrationError instanceof ApiError && registrationError.status === 404) { @@ -510,7 +560,7 @@ export function ServerCatalog() { true, ); await refreshCatalogSilently(); - return false; + return { success: false }; } reportNotification({ @@ -518,7 +568,7 @@ export function ServerCatalog() { type: "error", message: intl.formatMessage({ id: "mcpServer.catalog.addError" }), }); - return false; + return { success: false }; } finally { endAdding(server.id); } @@ -541,6 +591,11 @@ export function ServerCatalog() { setApiKeyServer(server); return; } + if (OAUTH_AUTH_TYPES.has(server.auth_type)) { + setOAuthDialogNotification(undefined); + setOAuthServer(server); + return; + } void registerServer(server); }, [registerServer], @@ -553,15 +608,122 @@ export function ServerCatalog() { // The dialog notification has no focus-ref registry like the grid's does, and doesn't // need one: InlineNotification already uses role="alert"/"status" for a11y announcement, // so shouldFocus is intentionally dropped here rather than passed to a state setter. - const registered = await registerServer(apiKeyServer, body, (notification) => + const { success } = await registerServer(apiKeyServer, body, (notification) => setApiKeyDialogNotification(notification), ); - if (registered) setFocusActionsForServerId(apiKeyServer.id); - return registered; + if (success) setFocusActionsForServerId(apiKeyServer.id); + return success; }, [apiKeyServer, registerServer], ); + const handleOAuthSubmit = useCallback( + async (body: CatalogOAuthRegisterBody) => { + if (!oauthServer) return false; + setOAuthDialogNotification(undefined); + let gatewayId = pendingOAuthGatewayId; + if (!gatewayId) { + const registration = await registerServer(oauthServer, body, (notification) => + setOAuthDialogNotification(notification), + ); + if (!registration.success) return false; + if (!registration.gatewayId) return true; + gatewayId = registration.gatewayId; + setPendingOAuthGatewayId(gatewayId); + } + + setOAuthAuthorizing(true); + setOAuthDialogNotification(undefined); + try { + await serversApi.triggerOAuthAuthorization(gatewayId); + await serversApi.toggleEnabled(gatewayId, true); + try { + await serversApi.fetchToolsAfterOAuth(gatewayId); + } catch { + showRegistrationNotification({ + id: `oauth:${oauthServer.id}`, + type: "info", + message: intl.formatMessage( + { id: "mcpServer.catalog.oauth.authorizedToolsPending" }, + { name: oauthServer.name }, + ), + }); + } + setData((current) => setCatalogServerOAuthPending(current, oauthServer.id, false)); + void refreshCatalogSilently(); + setPendingOAuthGatewayId(null); + setFocusActionsForServerId(oauthServer.id); + return true; + } catch (error) { + setOAuthDialogNotification({ + id: `oauth:${oauthServer.id}`, + type: "info", + message: + error instanceof Error + ? error.message + : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), + }); + return false; + } finally { + setOAuthAuthorizing(false); + } + }, + [ + intl, + oauthServer, + pendingOAuthGatewayId, + refreshCatalogSilently, + registerServer, + setData, + showRegistrationNotification, + ], + ); + + const handleAuthorize = useCallback( + async (server: CatalogServer) => { + if (!server.gateway_id || !beginAdding(server.id)) return; + dismissRegistrationNotification(`oauth:${server.id}`); + try { + await serversApi.triggerOAuthAuthorization(server.gateway_id); + await serversApi.toggleEnabled(server.gateway_id, true); + try { + await serversApi.fetchToolsAfterOAuth(server.gateway_id); + } catch { + showRegistrationNotification({ + id: `oauth:${server.id}`, + type: "info", + message: intl.formatMessage( + { id: "mcpServer.catalog.oauth.authorizedToolsPending" }, + { name: server.name }, + ), + }); + } + setData((current) => setCatalogServerOAuthPending(current, server.id, false)); + void refreshCatalogSilently(); + } catch (error) { + showRegistrationNotification({ + id: `oauth:${server.id}`, + type: "info", + message: + error instanceof Error + ? error.message + : intl.formatMessage({ id: "mcpServer.catalog.oauth.authorizationError" }), + }); + } finally { + endAdding(server.id); + } + }, + [ + beginAdding, + dismissRegistrationNotification, + endAdding, + intl, + refreshCatalogSilently, + setData, + showRegistrationNotification, + ], + ); + const handleTest = useCallback( async (server: CatalogServer) => { if (server.requires_oauth_config || isDisconnecting(server.id) || !beginTesting(server.id)) { @@ -978,11 +1140,13 @@ export function ServerCatalog() { onAdd={handleAdd} addingServerIds={addingServerIds} onTest={(server) => void handleTest(server)} + onAuthorize={(server) => void handleAuthorize(server)} onDisconnect={handleDisconnect} testingServerIds={testingServerIds} disconnectingServerIds={disconnectingServerIds} canTest={canTest} canDisconnect={canDisconnect} + oauthStatuses={oauthStatuses} /> @@ -1059,6 +1223,22 @@ export function ServerCatalog() { onDismissNotification={() => setApiKeyDialogNotification(undefined)} /> )} + {oauthServer && ( + { + if (!open) { + setOAuthServer(null); + setOAuthDialogNotification(undefined); + setPendingOAuthGatewayId(null); + } + }} + onSubmit={handleOAuthSubmit} + isSubmitting={addingServerIds.has(oauthServer.id) || oauthAuthorizing} + notification={oauthDialogNotification} + onDismissNotification={() => setOAuthDialogNotification(undefined)} + /> + )} ); } From 43904177860779e3af5804166e4dc6bc41b17373 Mon Sep 17 00:00:00 2001 From: Vishu Bhatnagar Date: Tue, 8 Sep 2026 16:24:51 +0100 Subject: [PATCH 2/2] docs: add OAuth catalog setup screenshot Signed-off-by: Vishu Bhatnagar --- docs/screenshots/catalog-oauth-setup.jpg | Bin 0 -> 40880 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/screenshots/catalog-oauth-setup.jpg diff --git a/docs/screenshots/catalog-oauth-setup.jpg b/docs/screenshots/catalog-oauth-setup.jpg new file mode 100644 index 0000000000000000000000000000000000000000..da9e6dc4b979316e2aa556ee0e0214dccda0e5b1 GIT binary patch literal 40880 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