Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions e2e/server-catalog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,20 @@ 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);
await page.waitForLoadState("networkidle");

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 }) => {
Expand Down
29 changes: 29 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion src/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { api } from "./client";
import type {
CatalogServerRegisterBody,
CatalogServerRegisterResponse,
GatewayRead,
GatewayTestRequest,
Expand All @@ -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<CatalogServerRegisterResponse> {
return api.post<CatalogServerRegisterResponse>(
`/v1/catalog/${encodeURIComponent(catalogId)}/register`,
body,
);
}

Expand Down
236 changes: 236 additions & 0 deletions src/components/server-catalog/CatalogApiKeyDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
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<Visibility>("private");
const [teamId, setTeamId] = useState("");
const [apiKeyError, setApiKeyError] = useState<string>();
const [teamError, setTeamError] = useState<string>();
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<HTMLFormElement>) => {
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 (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent>
<form onSubmit={(event) => void handleSubmit(event)}>
<DialogHeader>
<DialogTitle>
{intl.formatMessage({ id: "mcpServer.catalog.apiKey.title" }, { name: server.name })}
</DialogTitle>
<DialogDescription>
{intl.formatMessage({ id: "mcpServer.catalog.apiKey.description" })}
</DialogDescription>
</DialogHeader>

{notification && (
<div className="mt-4">
<InlineNotification
type={notification.type}
message={notification.message}
onDismiss={onDismissNotification}
/>
</div>
)}

<div className="space-y-5 py-5">
<div className="space-y-2.5">
<Label htmlFor="catalog-server-name">
{intl.formatMessage({ id: "mcpServer.catalog.apiKey.nameLabel" })}
</Label>
<Input
id="catalog-server-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={intl.formatMessage({ id: "mcpServer.catalog.apiKey.namePlaceholder" })}
disabled={isSubmitting}
/>
</div>

<div className="space-y-2.5">
<Label htmlFor="catalog-server-api-key">
{intl.formatMessage({ id: "mcpServer.catalog.apiKey.keyLabel" })}
<span className="text-destructive" aria-hidden="true">
{" "}
{intl.formatMessage({ id: "common.required" })}
</span>
</Label>
<Input
id="catalog-server-api-key"
type="password"
autoComplete="off"
maxLength={4096}
value={apiKey}
onChange={(event) => {
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 && (
<p id="catalog-server-api-key-error" className="text-sm text-destructive">
{apiKeyError}
</p>
)}
</div>

<div className="space-y-2.5">
<div className="flex items-center gap-1.5">
<Label htmlFor="catalog-server-visibility">
{intl.formatMessage({ id: "gateways.createServer.visibility" })}
</Label>
<VisibilityInfoPopover />
</div>
<Select
value={visibility}
onValueChange={(value: Visibility) => {
setVisibility(value);
setTeamError(undefined);
}}
disabled={isSubmitting}
>
{/* SelectTrigger is w-fit by default; full width lines it up with the inputs above. */}
<SelectTrigger id="catalog-server-visibility" className="w-full">
<SelectValue
placeholder={intl.formatMessage({
id: "mcpServer.advanced.visibilityPlaceholder",
})}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="private">
{intl.formatMessage({ id: "common.visibility.private" })}
</SelectItem>
<SelectItem value="team">
{intl.formatMessage({ id: "common.visibility.team" })}
</SelectItem>
{/* The API uses "public" for org-internal visibility; the UI label is "Internal". */}
<SelectItem value="public">
{intl.formatMessage({ id: "common.visibility.internal" })}
</SelectItem>
</SelectContent>
</Select>
</div>

{visibility === "team" && (
<TeamSelect
id="catalog-server-team"
teams={teams}
value={teamId || undefined}
onChange={onTeamChange}
error={teamError}
/>
)}
</div>

<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
{intl.formatMessage({ id: "common.button.cancel" })}
</Button>
<Button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>
{isSubmitting
? intl.formatMessage({ id: "mcpServer.catalog.adding" })
: intl.formatMessage({ id: "mcpServer.catalog.apiKey.submit" })}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
1 change: 1 addition & 0 deletions src/components/server-catalog/CatalogResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ function CatalogCard({
<DropdownMenuTrigger asChild>
<Button
ref={actionsTriggerRef}
id={`catalog-server-actions-${server.id}`}
type="button"
variant="ghost"
size="icon-xs"
Expand Down
Loading
Loading