diff --git a/e2e/server-catalog.spec.ts b/e2e/server-catalog.spec.ts index e654804..34cac73 100644 --- a/e2e/server-catalog.spec.ts +++ b/e2e/server-catalog.spec.ts @@ -77,7 +77,7 @@ test.describe("Server catalog page", () => { }); }); - test("lists only Open catalog servers and marks registered ones connected", async ({ page }) => { + test("lists supported catalog servers and marks registered ones connected", async ({ page }) => { await mockCatalog(page, [OPEN_CONNECTED, OPEN_AVAILABLE, API_KEY_SERVER]); await page.goto(APP.SERVER_CATALOG); @@ -85,12 +85,12 @@ test.describe("Server catalog page", () => { await expect(page.getByRole("heading", { name: "Server catalog" })).toBeVisible(); const catalogList = page.getByRole("list", { name: "Catalog servers" }); - await expect(catalogList.getByRole("listitem")).toHaveCount(2); + await expect(catalogList.getByRole("listitem")).toHaveCount(3); await expect(page.getByRole("heading", { name: "Globalping" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Public Notes" })).toBeVisible(); - await expect(page.getByText("Secret Service")).toHaveCount(0); + await expect(page.getByRole("heading", { name: "Secret Service" })).toBeVisible(); await expect(catalogList.getByText("Connected")).toBeVisible(); - await expect(page.getByText("2 servers shown")).toBeVisible(); + await expect(page.getByText("3 servers shown")).toBeVisible(); }); test("filters servers by search text and reflects it in the URL", async ({ page }) => { diff --git a/openapi.json b/openapi.json index 7fb971f..2c9e3c9 100644 --- a/openapi.json +++ b/openapi.json @@ -39850,6 +39850,35 @@ ], "title": "Api Key", "description": "API key if the catalog entry requires one" + }, + "visibility": { + "anyOf": [ + { + "type": "string", + "enum": [ + "private", + "team", + "public" + ] + }, + { + "type": "null" + } + ], + "title": "Visibility", + "description": "Visibility level: private, team, or public" + }, + "team_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Team Id", + "description": "Team ID for team-scoped registration" } }, "type": "object", diff --git a/src/api/catalog.ts b/src/api/catalog.ts index d946f2c..f97b914 100644 --- a/src/api/catalog.ts +++ b/src/api/catalog.ts @@ -1,5 +1,6 @@ import { api } from "./client"; import type { + CatalogServerRegisterBody, CatalogServerRegisterResponse, GatewayRead, GatewayTestRequest, @@ -13,12 +14,14 @@ export interface GatewayImpactPreview { export type CatalogGatewayDeleteResponse = GatewayRead | { status?: string; message?: string }; -/** Register an open catalog entry through the authenticated BFF proxy. */ +/** Register a catalog entry through the authenticated BFF proxy. */ export async function registerCatalogServer( catalogId: string, + body?: CatalogServerRegisterBody, ): Promise { return api.post( `/v1/catalog/${encodeURIComponent(catalogId)}/register`, + body, ); } diff --git a/src/components/server-catalog/CatalogApiKeyDialog.tsx b/src/components/server-catalog/CatalogApiKeyDialog.tsx new file mode 100644 index 0000000..193b0f9 --- /dev/null +++ b/src/components/server-catalog/CatalogApiKeyDialog.tsx @@ -0,0 +1,236 @@ +import { useCallback, useState } from "react"; +import { useIntl } from "react-intl"; + +import { TeamSelect } from "@/components/common/TeamSelect"; +import { VisibilityInfoPopover } from "@/components/common/VisibilityInfoPopover"; +import { Button } from "@/components/ui/button"; +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 type { CatalogServer, CatalogServerRegisterBody } from "@/generated/types"; +import { useTeamScope } from "@/hooks/useTeams"; +import type { Visibility } from "@/types/server"; + +export function CatalogApiKeyDialog({ + server, + onOpenChange, + onSubmit, + isSubmitting, + notification, + onDismissNotification, +}: { + server: CatalogServer; + onOpenChange: (open: boolean) => void; + onSubmit: (body: CatalogServerRegisterBody) => Promise; + isSubmitting: boolean; + notification?: { type: "success" | "error" | "info"; message: string }; + onDismissNotification?: () => void; +}) { + const intl = useIntl(); + const [name, setName] = useState(""); + const [apiKey, setApiKey] = useState(""); // pragma: allowlist secret + const [visibility, setVisibility] = useState("private"); + const [teamId, setTeamId] = useState(""); + const [apiKeyError, setApiKeyError] = useState(); + const [teamError, setTeamError] = useState(); + const { teams, onTeamChange } = useTeamScope({ + visibility, + teamId, + onTeamIdChange: setTeamId, + }); + + const reset = useCallback(() => { + setName(""); + setApiKey(""); + setVisibility("private"); + setTeamId(""); + setApiKeyError(undefined); + setTeamError(undefined); + }, []); + + 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 nextApiKeyError = apiKey.trim() + ? undefined + : intl.formatMessage({ id: "mcpServer.catalog.apiKey.required" }); + const nextTeamError = + visibility === "team" && !teamId + ? intl.formatMessage({ id: "mcpServer.catalog.apiKey.teamRequired" }) + : undefined; + setApiKeyError(nextApiKeyError); + setTeamError(nextTeamError); + if (nextApiKeyError || nextTeamError) return; + + const registered = await onSubmit({ + name: name.trim() || null, + api_key: apiKey, + visibility, + team_id: visibility === "team" ? teamId : null, + }); + if (registered) handleOpenChange(false); + }, + [apiKey, handleOpenChange, intl, name, onSubmit, teamId, visibility], + ); + + return ( + + +
void handleSubmit(event)}> + + + {intl.formatMessage({ id: "mcpServer.catalog.apiKey.title" }, { name: server.name })} + + + {intl.formatMessage({ id: "mcpServer.catalog.apiKey.description" })} + + + + {notification && ( +
+ +
+ )} + +
+
+ + setName(event.target.value)} + placeholder={intl.formatMessage({ id: "mcpServer.catalog.apiKey.namePlaceholder" })} + disabled={isSubmitting} + /> +
+ +
+ + { + setApiKey(event.target.value); + setApiKeyError(undefined); + }} + placeholder={intl.formatMessage({ id: "mcpServer.catalog.apiKey.keyPlaceholder" })} + aria-required="true" + aria-invalid={!!apiKeyError} + aria-describedby={apiKeyError ? "catalog-server-api-key-error" : undefined} + disabled={isSubmitting} + /> + {apiKeyError && ( +

+ {apiKeyError} +

+ )} +
+ +
+
+ + +
+ +
+ + {visibility === "team" && ( + + )} +
+ + + + + +
+
+
+ ); +} diff --git a/src/components/server-catalog/CatalogResults.tsx b/src/components/server-catalog/CatalogResults.tsx index 1178c74..73b3d5b 100644 --- a/src/components/server-catalog/CatalogResults.tsx +++ b/src/components/server-catalog/CatalogResults.tsx @@ -153,6 +153,7 @@ function CatalogCard({