diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 5b1cacb6a..5c96f3c51 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -38,6 +38,15 @@ html { outline-offset: 2px; } +/* Inputs in side drawers already have a visible control boundary. Color that + edge on focus so the focus treatment reads as one control, not a second + outline outside it. */ +[data-slot="dialog-content"][data-side="right"] + [data-slot="input"]:focus-visible { + outline: none; + border-color: var(--accent-rail, var(--primary)); +} + /* Brand type: Red Hat Display is loaded in index.html but nothing applied it — without this the whole app silently renders in the system fallback. Space Mono covers the places code/mono content asks for monospace. */ diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx index 8f2ba489f..e618ce583 100644 --- a/apps/web/src/pages/plugins-page.tsx +++ b/apps/web/src/pages/plugins-page.tsx @@ -20,6 +20,7 @@ import { PluginsGallery, PluginConnectPanel, type PluginsGalleryTab, + type PluginPanelSubject, } from "@corbits/plugins-ui"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; import { listPluginsForTenant } from "@corbits/connections/plugins"; @@ -81,7 +82,7 @@ export function PluginsRoute({ const [skillsState, setSkillsState] = useState({ status: "loading", }); - const [openPlugin, setOpenPlugin] = useState(null); + const [openPlugin, setOpenPlugin] = useState(null); const [createSkillOpen, setCreateSkillOpen] = useState(false); const [activeTab, setActiveTab] = useState("plugins"); const [galleryQuery, setGalleryQuery] = useState(""); @@ -100,7 +101,7 @@ export function PluginsRoute({ const clearPendingConnectProvider = useClearPendingConnectProvider(); const requestPluginsConnect = useRequestPluginsConnect(); const openPluginPanel = useCallback((plugin: ResolvedPlugin) => { - setOpenPlugin(plugin); + setOpenPlugin({ kind: "connector", plugin }); setConnectDeepLinkNotFound(false); }, []); @@ -373,9 +374,12 @@ export function PluginsRoute({ setOpenPlugin(null)} - onChanged={reloadPlugins} + onChanged={() => { + reloadPlugins(); + setOpenPlugin(null); + }} /> { // The delete resolved and `onChanged` fired `reloadPlugins`; its fetch // is now the deferred one above, still pending. + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); expect(el.textContent).not.toContain("Loading plugins…"); expect(el.textContent).toContain("GitHub"); diff --git a/packages/plugins-ui/src/index.ts b/packages/plugins-ui/src/index.ts index d4b5b6f25..b42468211 100644 --- a/packages/plugins-ui/src/index.ts +++ b/packages/plugins-ui/src/index.ts @@ -5,7 +5,10 @@ export { PluginCard } from "./plugin-card"; export { SkillCard } from "./skill-card"; export type { SkillCardData } from "./skill-card"; export { InstalledStrip } from "./installed-strip"; -export { PluginConnectPanel } from "./plugin-connect-panel"; +export { + PluginConnectPanel, + type PluginPanelSubject, +} from "./plugin-connect-panel"; export { McpServersSection } from "./mcp-servers-section"; export { PLUGINS_STRINGS } from "./strings"; diff --git a/packages/plugins-ui/src/mcp-preset-cards.tsx b/packages/plugins-ui/src/mcp-preset-cards.tsx index 0c04790b3..1fc582424 100644 --- a/packages/plugins-ui/src/mcp-preset-cards.tsx +++ b/packages/plugins-ui/src/mcp-preset-cards.tsx @@ -3,7 +3,7 @@ // connected custom servers share the same server-side store. import { reportError } from "@corbits/error-sink"; -import { Button, ConfirmButton, Input, toast } from "@corbits/react-ui"; +import { Button, toast } from "@corbits/react-ui"; import { CONNECTOR_REGISTRY, MCP_PRESETS, @@ -12,13 +12,11 @@ import { useEffect, useState } from "react"; import { connectMcpPreset, - disconnectMcpServer, listMcpPresets, mcpOAuthStartPath, type McpPreset, } from "./mcp-servers-api"; import { PluginLogo } from "./plugin-logo"; -import { PLUGINS_STRINGS } from "./strings"; function messageOf(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); @@ -71,57 +69,42 @@ export function McpPresetCard({ preset, toolCount, onChanged, + onOpen, }: { readonly tenantId: string; readonly preset: McpPreset; readonly toolCount: number | undefined; readonly onChanged: (toolCount?: number) => void; + readonly onOpen: (trigger: HTMLButtonElement) => void; }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(() => mcpOauthReturnError(preset.slug), ); - const [tokenFieldOpen, setTokenFieldOpen] = useState(false); - const [token, setToken] = useState(""); - - function submitConnect(pastedToken: string | undefined) { + function submitConnect() { setBusy(true); setError(null); - connectMcpPreset(tenantId, preset.slug, pastedToken) + connectMcpPreset(tenantId, preset.slug, undefined) .then((result) => { toast( `Connected — ${result.toolCount} tool${result.toolCount === 1 ? "" : "s"} available.`, ); - setTokenFieldOpen(false); - setToken(""); onChanged(result.toolCount); }) .catch((cause: unknown) => setError(messageOf(cause))) .finally(() => setBusy(false)); } - function handleConnect() { + function handleConnect(trigger: HTMLButtonElement) { if (preset.connectionMode === "oauth") { window.location.href = mcpOAuthStartPath(tenantId, preset.slug); return; } if (preset.connectionMode === "token") { - setTokenFieldOpen(true); + onOpen(trigger); return; } - submitConnect(undefined); - } - - function handleDisconnect() { - setBusy(true); - setError(null); - disconnectMcpServer(tenantId, preset.slug) - .then(() => { - toast(`${preset.displayName} disconnected.`); - onChanged(); - }) - .catch(() => setError(PLUGINS_STRINGS.disconnectError)) - .finally(() => setBusy(false)); + submitConnect(); } const presetDefinition = MCP_PRESETS.find( @@ -137,8 +120,6 @@ export function McpPresetCard({ : `${toolCount} tool${toolCount === 1 ? "" : "s"}` : "Not connected"; - const tokenFieldId = `mcp-preset-token-${preset.slug}`; - return (
{status} {preset.connected ? ( - - Disconnect - {preset.displayName} - - } - disabled={busy} - onConfirm={handleDisconnect} + variant="ghost" + aria-label={`Manage ${preset.displayName}`} + onClick={(event) => onOpen(event.currentTarget)} > - {busy ? "Disconnecting…" : "Manage"} + Manage {preset.displayName} - - ) : tokenFieldOpen ? null : ( + + ) : ( )}
- {tokenFieldOpen && !preset.connected ? ( -
-
    - {(preset.tokenSteps ?? []).map((step) => ( -
  1. {step}
  2. - ))} -
- - Create your token - - - { - setToken(event.target.value); - }} - /> -
- - -
-
- ) : null} ); } diff --git a/packages/plugins-ui/src/plugin-card.tsx b/packages/plugins-ui/src/plugin-card.tsx index 8a239af6f..4d6d2c973 100644 --- a/packages/plugins-ui/src/plugin-card.tsx +++ b/packages/plugins-ui/src/plugin-card.tsx @@ -7,6 +7,7 @@ import { Button } from "@corbits/react-ui"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; +import { oauthStartHref } from "@corbits/settings-ui"; import { pluginIcon, pluginOutcome } from "./plugin-meta"; import { PluginLogo } from "./plugin-logo"; @@ -25,9 +26,11 @@ const PROVENANCE_LABEL: Record<"this-workbench" | "inherited", string> = { }; export function PluginCard({ + tenantId, plugin, onOpen, }: { + readonly tenantId: string; readonly plugin: ResolvedPlugin; readonly onOpen: () => void; }) { @@ -36,6 +39,10 @@ export function PluginCard({ plugin.provenance !== null ? `${STATUS_CAPTION[plugin.status]} · ${PROVENANCE_LABEL[plugin.provenance]}` : STATUS_CAPTION[plugin.status]; + const isDirectOAuthConnect = + plugin.status === "not_connected" && + (plugin.descriptor.authKind === "oauth-pkce" || + plugin.descriptor.authKind === "oauth-code"); return (
{caption} - {plugin.status === "not_connected" ? ( + {isDirectOAuthConnect ? ( + + ) : plugin.status === "not_connected" ? ( {error !== null ? (

{error} @@ -187,16 +345,20 @@ function ConnectedSummary({ export function PluginConnectPanel({ tenantId, - plugin, + subject, onClose, onChanged, }: { readonly tenantId: string; - readonly plugin: ResolvedPlugin | null; + readonly subject: PluginPanelSubject | null; readonly onClose: () => void; - readonly onChanged: () => void; + readonly onChanged: (toolCount?: number) => void; }) { - const open = plugin !== null; + const open = subject !== null; + const plugin = subject?.kind === "connector" ? subject.plugin : null; + const preset = subject?.kind === "mcp-preset" ? subject.preset : null; + const toolCount = + subject?.kind === "mcp-preset" ? subject.toolCount : undefined; // CL-6830: probe is tri-state — never fold a failure into `{}`, which // reads as "hosted app absent" and hides one-click connect behind the // not-configured token paste. @@ -211,7 +373,7 @@ export function PluginConnectPanel({ const [oauthProbeKey, setOauthProbeKey] = useState(0); useEffect(() => { - if (!open) return; + if (plugin === null) return; let cancelled = false; setOauthProbe({ status: "loading" }); fetchOAuthConfigured(tenantId) @@ -224,7 +386,7 @@ export function PluginConnectPanel({ return () => { cancelled = true; }; - }, [open, tenantId, oauthProbeKey]); + }, [plugin, tenantId, oauthProbeKey]); const hostedAppAvailable = plugin?.descriptor.oauth !== undefined && @@ -238,20 +400,26 @@ export function PluginConnectPanel({ if (!next) onClose(); }} > - + - {plugin?.descriptor.displayName ?? ""} + + {plugin?.descriptor.displayName ?? preset?.displayName ?? ""} + - {plugin === null - ? "" - : pluginOutcome( + {plugin !== null + ? pluginOutcome( plugin.descriptor.id, plugin.descriptor.displayName, - )} + ) + : (preset?.description ?? "")} - {plugin === null ? null : ( - + {plugin !== null ? ( + {plugin.status !== "not_connected" ? ( ) : null} - )} - - - + ) : preset !== null ? ( + + + + ) : null} ); diff --git a/packages/plugins-ui/src/plugins-gallery.tsx b/packages/plugins-ui/src/plugins-gallery.tsx index da84ab2e6..9dff2f425 100644 --- a/packages/plugins-ui/src/plugins-gallery.tsx +++ b/packages/plugins-ui/src/plugins-gallery.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Lightning } from "@corbits/icons"; import { EmptyState, FilterChip, Tabs } from "@corbits/react-ui"; @@ -11,6 +11,7 @@ import { import { McpServersSection } from "./mcp-servers-section"; import { McpPresetCard, useMcpPresetCatalog } from "./mcp-preset-cards"; +import { PluginConnectPanel } from "./plugin-connect-panel"; import type { McpPreset } from "./mcp-servers-api"; import { PLUGIN_CATEGORY_ORDER, @@ -116,6 +117,7 @@ function PluginCatalogPanel({ onFilterChange, toolCounts, onPresetChanged, + onOpenPreset, onOpenPlugin, }: { readonly tenantId: string; @@ -128,6 +130,11 @@ function PluginCatalogPanel({ slug: string, toolCount: number | undefined, ) => void; + readonly onOpenPreset: ( + preset: McpPreset, + toolCount: number | undefined, + trigger: HTMLButtonElement, + ) => void; readonly onOpenPlugin: (plugin: ResolvedPlugin) => void; }) { const queryMatches = entries.filter((entry) => @@ -175,10 +182,14 @@ function PluginCatalogPanel({ preset={entry.preset} toolCount={toolCounts.get(entry.id)} onChanged={(toolCount) => onPresetChanged(entry.id, toolCount)} + onOpen={(trigger) => + onOpenPreset(entry.preset, toolCounts.get(entry.id), trigger) + } /> ) : ( onOpenPlugin(entry.plugin)} /> @@ -283,8 +294,25 @@ export function PluginsGallery({ readonly onAutoConnectPresetHandled?: () => void; }) { const [activeFilter, setActiveFilter] = useState("All"); + const [openPreset, setOpenPreset] = useState<{ + readonly preset: McpPreset; + readonly toolCount: number | undefined; + readonly trigger: HTMLButtonElement; + } | null>(null); + const presetFocusTrigger = useRef(null); const presetCatalog = useMcpPresetCatalog(tenantId); + useEffect(() => { + if (openPreset !== null) return; + presetFocusTrigger.current?.focus(); + presetFocusTrigger.current = null; + }, [openPreset]); + + function closePreset() { + presetFocusTrigger.current = openPreset?.trigger ?? null; + setOpenPreset(null); + } + const nativeEntries = useMemo( () => plugins @@ -384,6 +412,9 @@ export function PluginsGallery({ onFilterChange={setActiveFilter} toolCounts={presetCatalog.toolCounts} onPresetChanged={presetCatalog.handleChanged} + onOpenPreset={(preset, toolCount, trigger) => + setOpenPreset({ preset, toolCount, trigger }) + } onOpenPlugin={onOpenPlugin} /> )} @@ -399,6 +430,24 @@ export function PluginsGallery({

)} + { + if (openPreset === null) return; + presetCatalog.handleChanged(openPreset.preset.slug, toolCount); + closePreset(); + }} + />
); } diff --git a/packages/plugins-ui/test/mcp-preset-cards.test.tsx b/packages/plugins-ui/test/mcp-preset-cards.test.tsx index 2f60d5004..70a517939 100644 --- a/packages/plugins-ui/test/mcp-preset-cards.test.tsx +++ b/packages/plugins-ui/test/mcp-preset-cards.test.tsx @@ -11,6 +11,7 @@ import type { Root } from "react-dom/client"; import { MCP_PRESETS } from "@workbench/templates/connectors"; import { McpPresetCard, useMcpPresetCatalog } from "../src/mcp-preset-cards"; +import type { McpPreset } from "../src/mcp-servers-api"; const realFetch = globalThis.fetch; let mountedRoots: Root[] = []; @@ -24,18 +25,22 @@ afterEach(() => { const settle = () => act(() => new Promise((resolve) => setTimeout(resolve, 10))); -function mountSection() { +function mountSection(onOpen: (preset: McpPreset) => void = () => {}) { const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); mountedRoots.push(root); act(() => { - root.render(); + root.render(); }); return container; } -function PresetCatalogHarness() { +function PresetCatalogHarness({ + onOpen, +}: { + readonly onOpen: (preset: McpPreset) => void; +}) { const catalog = useMcpPresetCatalog("tenant_test"); if (!catalog.loaded) return null; if (catalog.loadError !== null) { @@ -52,6 +57,7 @@ function PresetCatalogHarness() { onChanged={(toolCount) => catalog.handleChanged(preset.slug, toolCount) } + onOpen={() => onOpen(preset)} /> ))} @@ -350,7 +356,7 @@ describe("MCP preset catalog", () => { expect(canvaCard.textContent).not.toContain("tools"); }); - test("Manage reveals a named Disconnect confirmation (CL-6794)", async () => { + test("Manage opens the preset drawer instead of expanding its catalog row", async () => { globalThis.fetch = (async () => new Response( JSON.stringify({ @@ -360,7 +366,8 @@ describe("MCP preset catalog", () => { }), )) as unknown as typeof fetch; - const container = mountSection(); + const opened: string[] = []; + const container = mountSection((preset) => opened.push(preset.slug)); await settle(); const exaCard = container.querySelector( @@ -369,16 +376,14 @@ describe("MCP preset catalog", () => { const manageExa = [...exaCard.querySelectorAll("button")].find( (button) => button.textContent?.includes("Manage") === true, ); - expect(manageExa?.textContent).toContain("Exa"); + expect(manageExa).not.toBeUndefined(); act(() => { manageExa?.click(); }); - const disconnectExa = [...exaCard.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Disconnect") === true, - ); - expect(disconnectExa?.textContent).toContain("Exa"); + expect(opened).toEqual(["exa"]); + expect(exaCard.querySelector("input")).toBeNull(); }); test("connect calls the preset connect route with the preset's slug", async () => { @@ -430,32 +435,19 @@ describe("MCP preset catalog", () => { expect(container.textContent).toContain("4 tools"); }); - test("a token preset opens step-by-step guidance and posts the pasted token", async () => { + test("a token preset opens its drawer without expanding the catalog row", async () => { const calls: { url: string; init?: RequestInit }[] = []; - let connected = false; globalThis.fetch = (async (url: string, init?: RequestInit) => { calls.push({ url, ...(init !== undefined ? { init } : {}) }); - if (init?.method === "POST") { - connected = true; - return new Response( - JSON.stringify({ - slug: "github-mcp", - name: "GitHub MCP", - url: "https://api.githubcopilot.com/mcp/", - toolCount: 40, - }), - ); - } return new Response( JSON.stringify({ - data: PRESETS.map((p) => - p.slug === "github-mcp" ? { ...p, connected } : p, - ), + data: PRESETS, }), ); }) as unknown as typeof fetch; - const container = mountSection(); + const opened: string[] = []; + const container = mountSection((preset) => opened.push(preset.slug)); await settle(); const card = container.querySelector( @@ -470,90 +462,10 @@ describe("MCP preset catalog", () => { await new Promise((resolve) => setTimeout(resolve, 10)); }); - // Opening the form is not a connect — no POST yet, steps visible. + // Opening the drawer is not a connect and does not change row height. expect(calls.find((call) => call.init?.method === "POST")).toBeUndefined(); - expect(card.textContent).toContain( - "Open github.com/settings/tokens and generate a new token.", - ); - expect(card.textContent).toContain("Give it the repo scope."); - expect( - card.querySelector('a[href="https://github.com/settings/tokens"]'), - ).not.toBeNull(); - - const field = card.querySelector( - "#mcp-preset-token-github-mcp", - ) as HTMLInputElement; - expect(field).not.toBeNull(); - await act(async () => { - const setter = Object.getOwnPropertyDescriptor( - HTMLInputElement.prototype, - "value", - )?.set; - setter?.call(field, "ghp_pasted"); - field.dispatchEvent(new Event("input", { bubbles: true })); - }); - - const submitButton = [...card.querySelectorAll("button")].find( - (button) => button.textContent === "Connect", - ) as HTMLButtonElement; - await act(async () => { - submitButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const connectCall = calls.find((call) => call.init?.method === "POST"); - expect(connectCall?.url).toBe("/api/tenants/tenant_test/mcp-servers"); - const body: unknown = JSON.parse(connectCall?.init?.body as string); - expect(body).toMatchObject({ - presetSlug: "github-mcp", - token: "ghp_pasted", - }); - expect(container.textContent).toContain("40 tools"); - }); - - test("disconnect calls DELETE on the preset's slug", async () => { - const calls: { url: string; init?: RequestInit }[] = []; - let deleted = false; - globalThis.fetch = (async (url: string, init?: RequestInit) => { - calls.push({ url, ...(init !== undefined ? { init } : {}) }); - if (init?.method === "DELETE") { - deleted = true; - return new Response(null, { status: 204 }); - } - return new Response( - JSON.stringify({ - data: PRESETS.map((p) => - p.slug === "exa" ? { ...p, connected: !deleted } : p, - ), - }), - ); - }) as unknown as typeof fetch; - - const container = mountSection(); - await settle(); - - const exaCard = container.querySelector( - '[data-plugin-slug="exa"]', - ) as HTMLElement; - const manageButton = [...exaCard.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Manage"), - ) as HTMLButtonElement; - - await act(async () => { - manageButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - const confirmButton = [...container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Disconnect") === true, - ) as HTMLButtonElement; - await act(async () => { - confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await new Promise((resolve) => setTimeout(resolve, 10)); - }); - - const deleteCall = calls.find((call) => call.init?.method === "DELETE"); - expect(deleteCall).not.toBeUndefined(); - expect(deleteCall?.url).toBe("/api/tenants/tenant_test/mcp-servers/exa"); + expect(opened).toEqual(["github-mcp"]); + expect(card.querySelector("input")).toBeNull(); }); // CL-6472: a fresh bench with zero connections still owns the same diff --git a/packages/plugins-ui/test/plugin-connect-panel.test.tsx b/packages/plugins-ui/test/plugin-connect-panel.test.tsx index 725d42ecf..e06af2cba 100644 --- a/packages/plugins-ui/test/plugin-connect-panel.test.tsx +++ b/packages/plugins-ui/test/plugin-connect-panel.test.tsx @@ -12,7 +12,11 @@ import type { Root } from "react-dom/client"; import type { ConnectorDescriptor } from "@corbits/connections/registry"; import type { ResolvedPlugin } from "@corbits/connections/plugins"; -import { PluginConnectPanel } from "../src/plugin-connect-panel"; +import { + PluginConnectPanel, + type PluginPanelSubject, +} from "../src/plugin-connect-panel"; +import type { McpPreset } from "../src/mcp-servers-api"; import { PLUGINS_STRINGS } from "../src/strings"; const realFetch = globalThis.fetch; @@ -66,7 +70,14 @@ const settle = () => // Dialog content renders through a Radix portal appended to // `document.body`, not inside the mount container — every assertion below // reads from `document.body` for that reason. -function render(plugin: ResolvedPlugin | null) { +function connectorSubject(plugin: ResolvedPlugin): PluginPanelSubject { + return { kind: "connector", plugin }; +} + +function render( + subject: PluginPanelSubject | null, + onChanged: (toolCount?: number) => void = () => {}, +) { const container = document.createElement("div"); document.body.appendChild(container); const root: Root = createRoot(container); @@ -75,9 +86,9 @@ function render(plugin: ResolvedPlugin | null) { root.render( {}} - onChanged={() => {}} + onChanged={onChanged} />, ); }); @@ -87,7 +98,9 @@ function render(plugin: ResolvedPlugin | null) { describe("PluginConnectPanel", () => { test("an oauth-pkce connector shows an OAuth connect link, not a key form", () => { const container = render( - notConnected(descriptor("huggingface", "Hugging Face", "oauth-pkce")), + connectorSubject( + notConnected(descriptor("huggingface", "Hugging Face", "oauth-pkce")), + ), ); const link = container.querySelector("a"); @@ -99,7 +112,9 @@ describe("PluginConnectPanel", () => { // CL-6377: one Connect action — no separate test step or "Test" copy. test("an api-key connector shows the connect form", () => { - const container = render(notConnected(descriptor("exa", "Exa", "api-key"))); + const container = render( + connectorSubject(notConnected(descriptor("exa", "Exa", "api-key"))), + ); expect(container.querySelector('input[type="password"]')).not.toBeNull(); expect(container.textContent).toContain("Connect"); @@ -131,7 +146,9 @@ describe("PluginConnectPanel", () => { }) as unknown as typeof fetch; const container = render( - notConnected(descriptor("granola", "Granola", "api-key")), + connectorSubject( + notConnected(descriptor("granola", "Granola", "api-key")), + ), ); await settle(); @@ -145,18 +162,23 @@ describe("PluginConnectPanel", () => { new Response(JSON.stringify({ error: "nope" }), { status: 500 }), )) as unknown as typeof fetch; - const container = render({ - descriptor: descriptor("github", "GitHub", "api-key"), - status: "connected", - provenance: "this-workbench", - credentialId: "cred_github", - credentialName: "GitHub", - }); + const container = render( + connectorSubject({ + descriptor: descriptor("github", "GitHub", "api-key"), + status: "connected", + provenance: "this-workbench", + credentialId: "cred_github", + credentialName: "GitHub", + }), + ); const disconnectButton = [...container.querySelectorAll("button")].find( (button) => button.textContent?.includes("Disconnect") === true, ); expect(disconnectButton).not.toBeUndefined(); + expect(disconnectButton?.className).toContain("border-input"); + expect(disconnectButton?.className).not.toContain("bg-destructive"); + expect(container.textContent).not.toContain("Close"); act(() => { disconnectButton?.dispatchEvent( @@ -195,7 +217,9 @@ describe("PluginConnectPanel", () => { headers: { "content-type": "application/json" }, })) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); const link = container.querySelector("a"); @@ -210,7 +234,9 @@ describe("PluginConnectPanel", () => { headers: { "content-type": "application/json" }, })) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); expect(container.textContent).toContain( @@ -225,7 +251,9 @@ describe("PluginConnectPanel", () => { globalThis.fetch = (() => Promise.reject(new Error("network down"))) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); expect(container.textContent).toContain("Couldn't check"); @@ -249,7 +277,9 @@ describe("PluginConnectPanel", () => { }); }) as unknown as typeof fetch; - const container = render(notConnected(githubDescriptor())); + const container = render( + connectorSubject(notConnected(githubDescriptor())), + ); await settle(); const retry = [...container.querySelectorAll("button")].find( @@ -272,4 +302,110 @@ describe("PluginConnectPanel", () => { expect(container.querySelector('input[type="password"]')).toBeNull(); expect(container.querySelector("a")).toBeNull(); }); + + test("a token preset renders its guidance and submits the pasted token", async () => { + const preset: McpPreset = { + slug: "github-mcp", + displayName: "GitHub MCP", + description: "Search code, work with issues and pull requests.", + url: "https://api.githubcopilot.com/mcp/", + connectionMode: "token", + docsUrl: "https://github.com/settings/tokens", + tokenSteps: ["Create a token with repo scope."], + connected: false, + }; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url, ...(init === undefined ? {} : { init }) }); + return new Response( + JSON.stringify({ + slug: preset.slug, + name: preset.displayName, + url: preset.url, + toolCount: 40, + }), + ); + }) as unknown as typeof fetch; + + const changed: number[] = []; + const container = render( + { kind: "mcp-preset", preset, toolCount: undefined }, + (toolCount) => { + if (toolCount !== undefined) changed.push(toolCount); + }, + ); + const field = container.querySelector( + "#mcp-preset-token-github-mcp", + ) as HTMLInputElement; + expect( + container.querySelector(`label[for="${field.id}"]`)?.textContent, + ).toContain("Personal access token"); + expect(field.autocomplete).toBe("new-password"); + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + setter?.call(field, "ghp_pasted"); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + const connect = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Connect", + ); + await act(async () => { + connect?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("/api/tenants/ten_1/mcp-servers"); + expect(JSON.parse(String(calls[0]?.init?.body))).toMatchObject({ + presetSlug: "github-mcp", + token: "ghp_pasted", + }); + expect(changed).toEqual([40]); + }); + + test("a connected preset disconnects from the drawer", async () => { + const preset: McpPreset = { + slug: "exa", + displayName: "Exa", + description: "Search and research the live web.", + url: "https://mcp.exa.ai/mcp", + connectionMode: "keyless", + docsUrl: "https://exa.ai", + connected: true, + }; + const calls: { url: string; init?: RequestInit }[] = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + calls.push({ url, ...(init === undefined ? {} : { init }) }); + return new Response(null, { status: 204 }); + }) as unknown as typeof fetch; + + const container = render({ + kind: "mcp-preset", + preset, + toolCount: 2, + }); + const disconnect = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Disconnect") === true, + ); + expect(disconnect?.className).toContain("border-input"); + expect(disconnect?.className).not.toContain("bg-destructive"); + expect(container.textContent).not.toContain("Close"); + act(() => { + disconnect?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const confirm = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Disconnect") === true, + ); + await act(async () => { + confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toBe("/api/tenants/ten_1/mcp-servers/exa"); + expect(calls[0]?.init?.method).toBe("DELETE"); + }); }); diff --git a/packages/plugins-ui/test/plugins-gallery.test.tsx b/packages/plugins-ui/test/plugins-gallery.test.tsx index dfccde63a..1996b44cb 100644 --- a/packages/plugins-ui/test/plugins-gallery.test.tsx +++ b/packages/plugins-ui/test/plugins-gallery.test.tsx @@ -29,8 +29,9 @@ function plugin( id: string, displayName: string, status: ResolvedPlugin["status"], + authKind: ConnectorDescriptor["authKind"] = "api-key", ): ResolvedPlugin { - const pluginDescriptor = descriptor(id, displayName); + const pluginDescriptor = descriptor(id, displayName, authKind); if (status === "not_connected") { return { descriptor: pluginDescriptor, @@ -294,6 +295,80 @@ describe("PluginsGallery", () => { expect(exa?.textContent).toContain("Manage"); }); + test("an OAuth plugin starts authorization from Connect instead of opening a drawer", async () => { + const { container } = await renderGallery([ + plugin("huggingface", "Hugging Face", "not_connected", "oauth-pkce"), + ]); + + const connect = container.querySelector( + '[aria-label="Connect Hugging Face"]', + ); + expect(connect?.tagName).toBe("A"); + expect(connect?.getAttribute("href")).toBe( + "/api/tenants/tenant_test/connections/oauth/huggingface/start?return=%2Fplugins", + ); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + }); + + test("a token preset keeps its catalog row compact and collects credentials in a drawer", async () => { + const { container } = await renderGallery(); + const githubMcp = container.querySelector( + '[data-plugin-slug="github-mcp"]', + ); + const connect = githubMcp?.querySelector( + '[aria-label="Connect GitHub MCP"]', + ) as HTMLButtonElement | null; + + act(() => connect?.click()); + + expect(githubMcp?.querySelector("input")).toBeNull(); + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog?.textContent).toContain("GitHub MCP"); + expect( + dialog?.querySelector("#mcp-preset-token-github-mcp"), + ).not.toBeNull(); + }); + + test("dismissing a token drawer discards its pasted token before reopening", async () => { + const { container } = await renderGallery(); + const connect = container.querySelector( + '[data-plugin-slug="github-mcp"] [aria-label="Connect GitHub MCP"]', + ) as HTMLButtonElement; + + connect.focus(); + act(() => connect.click()); + const field = document.body.querySelector( + "#mcp-preset-token-github-mcp", + ) as HTMLInputElement; + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )?.set; + await act(async () => { + setter?.call(field, "ghp_unsubmitted"); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(field.value).toBe("ghp_unsubmitted"); + + const close = document.body.querySelector( + '[role="dialog"] [aria-label="Close"]', + ) as HTMLButtonElement; + await act(async () => { + close.click(); + }); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + expect(document.activeElement).toBe(connect); + + act(() => connect.click()); + expect( + ( + document.body.querySelector( + "#mcp-preset-token-github-mcp", + ) as HTMLInputElement + ).value, + ).toBe(""); + }); + test("status remains visible as a core field", async () => { const { container } = await renderGallery(); const caption = [...container.querySelectorAll("span")].find(