diff --git a/docs/screenshots/catalog-oauth-setup.jpg b/docs/screenshots/catalog-oauth-setup.jpg new file mode 100644 index 0000000..da9e6dc Binary files /dev/null and b/docs/screenshots/catalog-oauth-setup.jpg differ diff --git a/src/api/catalog.test.ts b/src/api/catalog.test.ts index e2ee747..036a09b 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 f97b914..05f600a 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 0000000..3740030 --- /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 5adeb0c..83a331b 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 3949a8b..2fd2f56 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 43b7ca1..845cdc8 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 031eae9..7ac40fd 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 6a96be4..8f95a39 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 3e04065..9bd196e 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 f8c0f98..59e962d 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)} + /> + )} ); }