From 3b8770a91d2ec8c5e5f81ff3ed1395dc5619eb62 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:09:29 -0300 Subject: [PATCH 01/21] docs: add ConnectionsSetup component design Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../2026-02-23-connections-setup-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/plans/2026-02-23-connections-setup-design.md diff --git a/docs/plans/2026-02-23-connections-setup-design.md b/docs/plans/2026-02-23-connections-setup-design.md new file mode 100644 index 0000000000..cdf5466dbc --- /dev/null +++ b/docs/plans/2026-02-23-connections-setup-design.md @@ -0,0 +1,136 @@ +# ConnectionsSetup Component Design + +**Date:** 2026-02-23 +**Status:** Approved + +## Overview + +`ConnectionsSetup` is a context-agnostic React component that guides a user through installing, authenticating, and configuring a declared set of MCP connections. It is used in onboarding flows (project templates, agent installs) where a known set of MCPs must be present and ready before the user can proceed. + +It renders all slots vertically — no multi-step navigation. Satisfied slots collapse; unsatisfied slots show inline forms. + +## Component API + +```typescript +type ConnectionSlot = { + label: string + registry: string // registry connection app_name or id + item_id: string // registry item id +} + +type ConnectionsSetupProps = { + slots: Record + onComplete: (connections: Record) => void // slotId → connection_id +} +``` + +The component owns no persistence. Callers fetch specs from wherever they live (agent records, template configs, etc.) and pass them as props. `onComplete` fires once every slot is satisfied — the returned record maps each slot ID to the resolved connection ID, leaving wiring decisions entirely to the caller. + +## Slot Resolution + +On mount, each slot queries existing connections filtered by `metadata.registry_item_id === slot.item_id`. + +- **One+ found, at least one satisfied** → `DONE` (first satisfied match selected) +- **One+ found, none satisfied** → `PICKER` (user selects an existing connection or installs fresh) +- **None found** → `FORM` (straight to install) +- **Registry item not found** → `ERROR` ("Registry item not found. Check your registry connection.") + +## Render States + +### DONE +Collapsed card showing connection name and icon. Includes a `[change ▾]` button that reopens the PICKER or FORM inline. + +### LOADING +Skeleton card shown during initial slot resolution. + +### PICKER +Shown when compatible connections already exist. Displays a dropdown of existing matching connections. User can select one and confirm, or choose "Install fresh" to expand the FORM inline. + +### FORM (INSTALL → AUTH → CONFIG → DONE) +A single slot card that progresses through up to three sub-phases: + +**INSTALL** +- `extractConnectionData(registryItem)` pre-fills connection params +- User submits → `CONNECTION_CREATE` mutation +- On success → determine next phase based on registry item metadata + +**AUTH — OAuth** (if registry item has `oauth_config`) +- Renders "Authorize with [Provider]" button +- Opens OAuth redirect (same flow as connection detail page) +- On `oauth-callback` → polls for `status === "active"` + +**AUTH — Token** (if registry item requires token/header, no OAuth) +- Renders token or header input +- Submit → `CONNECTION_UPDATE` (encrypted via vault) +- Polls for `status === "active"` + +**AUTH — None** (MCP works without auth) +- Skipped. Polls for `status === "active"` immediately after install. + +**CONFIG** (only if connection has a `configuration_state` schema) +- Renders form derived from the config schema +- Submit → `CONNECTION_CONFIGURE` (or equivalent update) + +**Polling**: after each mutation, poll `CONNECTION_GET` every 2s, up to 15s, waiting for `status === "active"`. On timeout → error state with retry option on the slot. + +## Satisfaction Criteria + +A connection is considered satisfied when: +1. `status === "active"` (tools listed successfully, no 401) +2. If the connection has a `configuration_state` schema → config has been submitted and is valid + +`onComplete` fires when every slot in the `slots` record is simultaneously satisfied. + +## File Structure + +``` +apps/mesh/src/web/components/connections-setup/ + index.ts ← re-exports ConnectionsSetup + connections-setup.tsx ← root component, maps slots → SlotCard list + slot-card.tsx ← single slot, owns phase state machine + slot-install-form.tsx ← INSTALL phase + slot-auth-oauth.tsx ← AUTH phase — OAuth button + polling + slot-auth-token.tsx ← AUTH phase — token/header input + slot-config-form.tsx ← CONFIG phase — config schema form + slot-done.tsx ← collapsed done card with [change] + use-slot-resolution.ts ← resolves initial slot state from existing connections + use-connection-poller.ts ← polls CONNECTION_GET until active or timeout +``` + +## Usage Examples + +**Project template onboarding:** +```tsx + { + enablePlugins(projectId, ["chat", "email"]) + wireConnections(projectId, { modelConnectionId: model, emailConnectionId: email }) + redirect(`/${org}/${project}`) + }} +/> +``` + +**Marketplace agent install:** +```tsx + { + wireToAgent(agentId, connections) + closeDialog() + }} +/> +``` + +## What This Is Not + +- Not a multi-step wizard — no next/back navigation +- Not responsible for persisting slot specs — caller owns that +- Not responsible for wiring connections to agents/projects — caller owns that via `onComplete` +- Does not support binding-based (flexible) slots — exact registry item reference only for now From fda11262f211c22e3238fc29a7db63017790b053 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:17:56 -0300 Subject: [PATCH 02/21] docs: add ConnectionsSetup implementation plan Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../2026-02-23-connections-setup-plan.md | 1195 +++++++++++++++++ 1 file changed, 1195 insertions(+) create mode 100644 docs/plans/2026-02-23-connections-setup-plan.md diff --git a/docs/plans/2026-02-23-connections-setup-plan.md b/docs/plans/2026-02-23-connections-setup-plan.md new file mode 100644 index 0000000000..c64c35fd8b --- /dev/null +++ b/docs/plans/2026-02-23-connections-setup-plan.md @@ -0,0 +1,1195 @@ +# ConnectionsSetup Component Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a `ConnectionsSetup` component that guides users through installing, authenticating, and verifying a declared set of MCP connections—used in onboarding flows and marketplace agent installs. + +**Architecture:** Context-agnostic component that accepts a `Record` prop and fires `onComplete(Record)` when all slots are satisfied. Each slot resolves against existing connections by `metadata.registry_item_id` and renders either a done card, a picker (existing connections), or an inline install+auth flow. No multi-step navigation—all slots are visible at once, satisfied slots collapse. + +**Tech Stack:** React 19, TanStack React Query v5, react-hook-form + Zod, `@decocms/mesh-sdk` hooks (`useConnectionActions`, `useConnections`, `useProjectContext`, `createMCPClient`, `SELF_MCP_ALIAS_ID`, `authenticateMcp`, `isConnectionAuthenticated`), existing utilities: `extractConnectionData`, `callRegistryTool`, `extractItemsFromResponse`, `findListToolName` from `@/web/utils/`. + +--- + +## Reference: Key Imports + +```typescript +// From @decocms/mesh-sdk +import { + useConnectionActions, + useConnections, + useProjectContext, + createMCPClient, + SELF_MCP_ALIAS_ID, + authenticateMcp, + isConnectionAuthenticated, + type ConnectionEntity, + type McpAuthStatus, +} from "@decocms/mesh-sdk"; + +// Internal utilities +import { extractConnectionData } from "@/web/utils/extract-connection-data"; +import { + callRegistryTool, + extractItemsFromResponse, + findListToolName, +} from "@/web/utils/registry-utils"; +import { useRegistryConnections } from "@/web/hooks/use-binding"; +import { KEYS } from "@/web/lib/query-keys"; + +// React Query +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +// Auth +import { authClient } from "@/web/lib/auth-client"; + +// Types +import type { RegistryItem } from "@/web/components/store/types"; +``` + +## Reference: Slot Types + +```typescript +// Defined in connections-setup.tsx and re-exported via index.ts +export interface ConnectionSlot { + label: string; + registry: string; // registry connection id or app_name + item_id: string; // registry item id (matched against metadata.registry_item_id) +} + +export interface ConnectionsSetupProps { + slots: Record; + onComplete: (connections: Record) => void; // slotId → connection_id +} +``` + +## Reference: Phase State Machine (per slot) + +``` +loading → picker (existing matching connections found) + → install (no matching connections) + +picker → done (user selects a satisfied connection) + → install (user clicks "Install fresh") + +install → polling (after successful CONNECTION_CREATE) + +polling → done (connection.status === "active") + → auth-oauth (timeout/error + supportsOAuth) + → auth-token (timeout/error + !supportsOAuth) + +auth-oauth → polling (after authenticateMcp success + update trigger) +auth-token → polling (after token save + update trigger) + +done → picker (user clicks [change]) + → install (no existing connections when [change] clicked) +``` + +--- + +## Task 1: Add query keys for registry item and connection polling + +**Files:** +- Modify: `apps/mesh/src/web/lib/query-keys.ts` + +**Step 1: Read the file** + +Open `apps/mesh/src/web/lib/query-keys.ts` and find the `KEYS` object. + +**Step 2: Add two new keys** + +Add after the `isMCPAuthenticated` key: + +```typescript + registryItem: (registryId: string, itemId: string) => + ["registry-item", registryId, itemId] as const, + + connectionPoll: (connectionId: string) => + ["connection-poll", connectionId] as const, +``` + +**Step 3: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/lib/query-keys.ts +git commit -m "feat(connections-setup): add registry-item and connection-poll query keys" +``` + +--- + +## Task 2: Pure slot resolution logic + test + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/slot-resolution.ts` +- Create: `apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts` + +**Step 1: Write the failing test** + +Create `apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts`: + +```typescript +import { describe, expect, it } from "bun:test"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { resolveInitialPhase } from "./slot-resolution"; + +function makeConn( + overrides: Partial & { metadata?: Record }, +): ConnectionEntity { + return { + id: "conn_test", + title: "Test", + status: "inactive", + connection_type: "HTTP", + connection_url: null, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + description: null, + icon: null, + app_name: null, + app_id: null, + tools: null, + bindings: null, + organization_id: "org_1", + created_by: "user_1", + updated_by: "user_1", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + } as unknown as ConnectionEntity; +} + +describe("resolveInitialPhase", () => { + it("returns 'install' when no matching connections exist", () => { + const connections: ConnectionEntity[] = [ + makeConn({ metadata: { registry_item_id: "other-item" } }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("install"); + }); + + it("returns 'done' when a matching active connection exists", () => { + const connections: ConnectionEntity[] = [ + makeConn({ + id: "conn_active", + status: "active", + metadata: { registry_item_id: "my-item" }, + }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("done"); + }); + + it("returns 'picker' when matching connections exist but none are active", () => { + const connections: ConnectionEntity[] = [ + makeConn({ + id: "conn_inactive", + status: "inactive", + metadata: { registry_item_id: "my-item" }, + }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("picker"); + }); + + it("returns 'done' for first active match when multiple exist", () => { + const connections: ConnectionEntity[] = [ + makeConn({ status: "inactive", metadata: { registry_item_id: "my-item" } }), + makeConn({ id: "conn_2", status: "active", metadata: { registry_item_id: "my-item" } }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("done"); + }); +}); + +describe("findMatchingConnections", () => { + it("filters connections by registry_item_id", () => { + const connections: ConnectionEntity[] = [ + makeConn({ id: "conn_a", metadata: { registry_item_id: "item-1" } }), + makeConn({ id: "conn_b", metadata: { registry_item_id: "item-2" } }), + makeConn({ id: "conn_c", metadata: { registry_item_id: "item-1" } }), + ]; + const { findMatchingConnections } = await import("./slot-resolution"); + const result = findMatchingConnections(connections, "item-1"); + expect(result).toHaveLength(2); + expect(result.map((c) => c.id)).toEqual(["conn_a", "conn_c"]); + }); +}); +``` + +**Step 2: Run test to confirm it fails** + +```bash +bun test apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts +``` + +Expected: FAIL — module not found. + +**Step 3: Create the implementation** + +Create `apps/mesh/src/web/components/connections-setup/slot-resolution.ts`: + +```typescript +import type { ConnectionEntity } from "@decocms/mesh-sdk"; + +export type SlotPhase = + | "loading" + | "picker" + | "install" + | "polling" + | "auth-oauth" + | "auth-token" + | "done"; + +export function findMatchingConnections( + connections: ConnectionEntity[], + itemId: string, +): ConnectionEntity[] { + return connections.filter( + (c) => + (c.metadata as Record | null)?.registry_item_id === itemId, + ); +} + +export function resolveInitialPhase( + connections: ConnectionEntity[], + itemId: string, +): "done" | "picker" | "install" { + const matches = findMatchingConnections(connections, itemId); + if (matches.length === 0) return "install"; + const hasActive = matches.some((c) => c.status === "active"); + return hasActive ? "done" : "picker"; +} +``` + +**Step 4: Run tests to confirm they pass** + +```bash +bun test apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts +``` + +Expected: PASS (4 tests). + +**Step 5: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/ +git commit -m "feat(connections-setup): add slot resolution pure logic with tests" +``` + +--- + +## Task 3: `use-slot-resolution.ts` hook + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts` + +**Step 1: Write the hook** + +This hook combines the pure resolution logic with async registry item fetching. + +Create `apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts`: + +```typescript +import { useQuery } from "@tanstack/react-query"; +import { + useConnections, + useProjectContext, + type ConnectionEntity, +} from "@decocms/mesh-sdk"; +import { useRegistryConnections } from "@/web/hooks/use-binding"; +import { + callRegistryTool, + extractItemsFromResponse, + findListToolName, +} from "@/web/utils/registry-utils"; +import { KEYS } from "@/web/lib/query-keys"; +import type { RegistryItem } from "@/web/components/store/types"; +import { + findMatchingConnections, + resolveInitialPhase, + type SlotPhase, +} from "./slot-resolution"; + +export interface ConnectionSlot { + label: string; + registry: string; + item_id: string; +} + +export interface SlotResolution { + initialPhase: SlotPhase; + registryItem: RegistryItem | null; + matchingConnections: ConnectionEntity[]; + satisfiedConnection: ConnectionEntity | null; + isLoading: boolean; + registryError: string | null; +} + +export function useSlotResolution(slot: ConnectionSlot): SlotResolution { + const { org } = useProjectContext(); + const allConnections = useConnections(); + const registryConnections = useRegistryConnections(allConnections); + + const registryConn = registryConnections.find( + (c) => c.id === slot.registry || c.app_name === slot.registry, + ); + + const { data: registryItem, isLoading: isLoadingItem } = useQuery({ + queryKey: KEYS.registryItem(slot.registry, slot.item_id), + queryFn: async (): Promise => { + if (!registryConn) return null; + const listTool = findListToolName(registryConn.tools); + if (!listTool) return null; + const result = await callRegistryTool( + registryConn.id, + org.id, + listTool, + { where: { id: slot.item_id } }, + ); + const items = extractItemsFromResponse(result); + return items[0] ?? null; + }, + enabled: Boolean(registryConn && org), + staleTime: 60 * 60 * 1000, + }); + + const connections = allConnections ?? []; + const matchingConnections = findMatchingConnections(connections, slot.item_id); + const satisfiedConnection = + matchingConnections.find((c) => c.status === "active") ?? null; + + if (!allConnections || isLoadingItem) { + return { + initialPhase: "loading", + registryItem: null, + matchingConnections: [], + satisfiedConnection: null, + isLoading: true, + registryError: null, + }; + } + + const registryError = + !isLoadingItem && !registryItem ? "Registry item not found." : null; + + const initialPhase = resolveInitialPhase(connections, slot.item_id); + + return { + initialPhase, + registryItem: registryItem ?? null, + matchingConnections, + satisfiedConnection, + isLoading: false, + registryError, + }; +} +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts +git commit -m "feat(connections-setup): add use-slot-resolution hook" +``` + +--- + +## Task 4: `use-connection-poller.ts` hook + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/use-connection-poller.ts` + +**Step 1: Write the hook** + +Polls `COLLECTION_CONNECTIONS_GET` every 2s until `status === "active"` or `status === "error"`. Stops after 15s (timeout). + +Create `apps/mesh/src/web/components/connections-setup/use-connection-poller.ts`: + +```typescript +import { useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + createMCPClient, + SELF_MCP_ALIAS_ID, + useProjectContext, + type ConnectionEntity, +} from "@decocms/mesh-sdk"; +import { KEYS } from "@/web/lib/query-keys"; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 15000; + +export interface ConnectionPollerResult { + connection: ConnectionEntity | null; + isActive: boolean; + isTimedOut: boolean; + isPolling: boolean; +} + +export function useConnectionPoller( + connectionId: string | null, +): ConnectionPollerResult { + const { org } = useProjectContext(); + const startTimeRef = useRef(0); + + if (connectionId && startTimeRef.current === 0) { + startTimeRef.current = Date.now(); + } + if (!connectionId) { + startTimeRef.current = 0; + } + + const { data: connection } = useQuery({ + queryKey: KEYS.connectionPoll(connectionId ?? ""), + queryFn: async (): Promise => { + if (!connectionId) return null; + const client = await createMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + try { + const result = (await client.callTool({ + name: "COLLECTION_CONNECTIONS_GET", + arguments: { id: connectionId }, + })) as { structuredContent?: ConnectionEntity } | ConnectionEntity; + return ( + (result as { structuredContent?: ConnectionEntity }).structuredContent ?? + (result as ConnectionEntity) + ); + } finally { + await client.close().catch(console.error); + } + }, + refetchInterval: (query) => { + const conn = query.state.data; + if (!connectionId) return false; + if (conn?.status === "active" || conn?.status === "error") return false; + if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) return false; + return POLL_INTERVAL_MS; + }, + enabled: Boolean(connectionId && org), + staleTime: 0, + }); + + const isTimedOut = + Boolean(connectionId) && + startTimeRef.current > 0 && + Date.now() - startTimeRef.current > POLL_TIMEOUT_MS && + connection?.status !== "active"; + + return { + connection: connection ?? null, + isActive: connection?.status === "active", + isTimedOut, + isPolling: + Boolean(connectionId) && + connection?.status !== "active" && + connection?.status !== "error" && + !isTimedOut, + }; +} +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/use-connection-poller.ts +git commit -m "feat(connections-setup): add use-connection-poller hook" +``` + +--- + +## Task 5: `slot-done.tsx` — collapsed done state + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/slot-done.tsx` + +**Step 1: Write the component** + +Create `apps/mesh/src/web/components/connections-setup/slot-done.tsx`: + +```typescript +import { CheckCircle, ChevronDown } from "lucide-react"; +import { Button } from "@deco/ui/components/button.tsx"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; + +interface SlotDoneProps { + label: string; + connection: ConnectionEntity; + onReset: () => void; +} + +export function SlotDone({ label, connection, onReset }: SlotDoneProps) { + return ( +
+ +
+

{label}

+

{connection.title}

+
+ +
+ ); +} +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/slot-done.tsx +git commit -m "feat(connections-setup): add slot-done component" +``` + +--- + +## Task 6: `slot-install-form.tsx` — install phase + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/slot-install-form.tsx` + +**Step 1: Write the component** + +Pre-fills a connection form from the registry item via `extractConnectionData`. On submit creates the connection. + +Create `apps/mesh/src/web/components/connections-setup/slot-install-form.tsx`: + +```typescript +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + useConnectionActions, + useProjectContext, + type ConnectionEntity, +} from "@decocms/mesh-sdk"; +import { authClient } from "@/web/lib/auth-client"; +import { extractConnectionData } from "@/web/utils/extract-connection-data"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@deco/ui/components/form.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import type { RegistryItem } from "@/web/components/store/types"; + +const installSchema = z.object({ + title: z.string().min(1, "Name is required"), +}); + +type InstallFormData = z.infer; + +interface SlotInstallFormProps { + registryItem: RegistryItem; + onInstalled: (connectionId: string) => void; +} + +export function SlotInstallForm({ + registryItem, + onInstalled, +}: SlotInstallFormProps) { + const { org } = useProjectContext(); + const { data: session } = authClient.useSession(); + const actions = useConnectionActions(); + + const connectionData = extractConnectionData( + registryItem, + org.id, + session?.user?.id ?? "system", + ); + + const form = useForm({ + resolver: zodResolver(installSchema), + defaultValues: { title: connectionData.title ?? "" }, + }); + + const handleSubmit = async (data: InstallFormData) => { + const payload: ConnectionEntity = { + ...(connectionData as ConnectionEntity), + title: data.title, + }; + await actions.create.mutateAsync(payload); + onInstalled(connectionData.id); + }; + + return ( +
+ + ( + + Connection name + + + + + + )} + /> + + + + ); +} +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/slot-install-form.tsx +git commit -m "feat(connections-setup): add slot-install-form component" +``` + +--- + +## Task 7: `slot-auth-oauth.tsx` — OAuth authorize phase + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx` + +**Step 1: Write the component** + +Mirrors `handleAuthenticate` from `apps/mesh/src/web/components/details/connection/index.tsx:340-409`. + +Create `apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx`: + +```typescript +import { useState } from "react"; +import { toast } from "sonner"; +import { + authenticateMcp, + useConnectionActions, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { useQueryClient } from "@tanstack/react-query"; +import { KEYS } from "@/web/lib/query-keys"; +import { Button } from "@deco/ui/components/button.tsx"; + +interface SlotAuthOAuthProps { + connectionId: string; + providerName: string; + onAuthed: () => void; +} + +export function SlotAuthOAuth({ + connectionId, + providerName, + onAuthed, +}: SlotAuthOAuthProps) { + const [isPending, setIsPending] = useState(false); + const actions = useConnectionActions(); + const queryClient = useQueryClient(); + const mcpProxyUrl = new URL(`/mcp/${connectionId}`, window.location.origin); + + const handleAuthorize = async () => { + setIsPending(true); + try { + const { token, tokenInfo, error } = await authenticateMcp({ connectionId }); + + if (error || !token) { + toast.error(`Authorization failed: ${error ?? "Unknown error"}`); + return; + } + + if (tokenInfo) { + const response = await fetch(`/api/connections/${connectionId}/oauth-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + accessToken: tokenInfo.accessToken, + refreshToken: tokenInfo.refreshToken, + expiresIn: tokenInfo.expiresIn, + scope: tokenInfo.scope, + clientId: tokenInfo.clientId, + clientSecret: tokenInfo.clientSecret, + tokenEndpoint: tokenInfo.tokenEndpoint, + }), + }); + if (!response.ok) { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } else { + // Trigger tool re-discovery + await actions.update.mutateAsync({ id: connectionId, data: {} }); + } + } else { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } + + await queryClient.invalidateQueries({ + queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), + }); + + onAuthed(); + } finally { + setIsPending(false); + } + }; + + return ( +
+

+ Authorize Mesh to access {providerName} on your behalf. +

+ +
+ ); +} +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx +git commit -m "feat(connections-setup): add slot-auth-oauth component" +``` + +--- + +## Task 8: `slot-auth-token.tsx` — token input phase + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/slot-auth-token.tsx` + +**Step 1: Write the component** + +Create `apps/mesh/src/web/components/connections-setup/slot-auth-token.tsx`: + +```typescript +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useConnectionActions } from "@decocms/mesh-sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@deco/ui/components/form.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; + +const tokenSchema = z.object({ + token: z.string().min(1, "Token is required"), +}); + +type TokenFormData = z.infer; + +interface SlotAuthTokenProps { + connectionId: string; + onAuthed: () => void; +} + +export function SlotAuthToken({ connectionId, onAuthed }: SlotAuthTokenProps) { + const actions = useConnectionActions(); + + const form = useForm({ + resolver: zodResolver(tokenSchema), + defaultValues: { token: "" }, + }); + + const handleSubmit = async (data: TokenFormData) => { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: data.token }, + }); + // Trigger tool re-discovery + await actions.update.mutateAsync({ id: connectionId, data: {} }); + onAuthed(); + }; + + return ( +
+ + ( + + API Token + + + + + + )} + /> + + + + ); +} +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/slot-auth-token.tsx +git commit -m "feat(connections-setup): add slot-auth-token component" +``` + +--- + +## Task 9: `slot-card.tsx` — full phase state machine + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/slot-card.tsx` + +This is the main orchestrator. It owns the per-slot phase state and transitions between all phases. + +**Step 1: Write the component** + +Create `apps/mesh/src/web/components/connections-setup/slot-card.tsx`: + +```typescript +import { useState } from "react"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { isConnectionAuthenticated } from "@decocms/mesh-sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useSlotResolution, type ConnectionSlot } from "./use-slot-resolution"; +import { useConnectionPoller } from "./use-connection-poller"; +import { findMatchingConnections, type SlotPhase } from "./slot-resolution"; +import { SlotDone } from "./slot-done"; +import { SlotInstallForm } from "./slot-install-form"; +import { SlotAuthOAuth } from "./slot-auth-oauth"; +import { SlotAuthToken } from "./slot-auth-token"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; + +interface SlotCardProps { + slot: ConnectionSlot; + onComplete: (connectionId: string) => void; +} + +export function SlotCard({ slot, onComplete }: SlotCardProps) { + const resolution = useSlotResolution(slot); + const [phase, setPhase] = useState(null); + const [pollingConnectionId, setPollingConnectionId] = useState(null); + const [selectedConnection, setSelectedConnection] = useState(null); + + const poller = useConnectionPoller(pollingConnectionId); + + // Derive effective phase: explicit override takes priority, else from resolution + const effectivePhase: SlotPhase = phase ?? resolution.initialPhase; + + // React to poller becoming active + if (pollingConnectionId && poller.isActive && poller.connection) { + setPollingConnectionId(null); + setSelectedConnection(poller.connection); + setPhase("done"); + onComplete(poller.connection.id); + } + + // React to poller timeout/error — determine auth type needed + if ( + pollingConnectionId && + (poller.isTimedOut || poller.connection?.status === "error") + ) { + const connectionId = pollingConnectionId; + setPollingConnectionId(null); + + // Async: check auth status to determine next phase + const url = new URL(`/mcp/${connectionId}`, window.location.origin).href; + isConnectionAuthenticated({ url, token: null }).then((authStatus) => { + if (authStatus.supportsOAuth) { + setPhase("auth-oauth"); + } else { + setPhase("auth-token"); + } + }); + } + + const handleInstalled = (connectionId: string) => { + setPollingConnectionId(connectionId); + setPhase("polling"); + }; + + const handleAuthed = () => { + // Re-enter polling after auth — poller will reset via connectionId change + const id = pollingConnectionId ?? selectedConnection?.id ?? null; + if (id) { + setPollingConnectionId(id); + setPhase("polling"); + } + }; + + const handleReset = () => { + const hasExisting = resolution.matchingConnections.length > 0; + setPhase(hasExisting ? "picker" : "install"); + setSelectedConnection(null); + setPollingConnectionId(null); + onComplete(""); // signal slot is no longer complete + }; + + const resolvedConnection = + selectedConnection ?? + resolution.satisfiedConnection ?? + null; + + if (effectivePhase === "loading") { + return ( +
+ +

{slot.label}

+
+ ); + } + + if (resolution.registryError) { + return ( +
+ +
+

{slot.label}

+

{resolution.registryError}

+
+
+ ); + } + + return ( +
+

{slot.label}

+ + {effectivePhase === "done" && resolvedConnection && ( + + )} + + {effectivePhase === "picker" && ( +
+

Already installed:

+
+ {resolution.matchingConnections.map((conn) => ( + + ))} +
+ +
+ )} + + {effectivePhase === "install" && resolution.registryItem && ( + + )} + + {effectivePhase === "polling" && ( +
+ + Connecting... +
+ )} + + {effectivePhase === "auth-oauth" && selectedConnection && ( + + )} + + {effectivePhase === "auth-token" && selectedConnection && ( + + )} +
+ ); +} +``` + +> **Note:** The `onComplete("")` call in `handleReset` is a sentinel to signal the slot is no longer done. `connections-setup.tsx` will filter out empty strings when checking completion. + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/slot-card.tsx +git commit -m "feat(connections-setup): add slot-card state machine component" +``` + +--- + +## Task 10: `connections-setup.tsx` — root component + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/connections-setup.tsx` + +**Step 1: Write the component** + +Create `apps/mesh/src/web/components/connections-setup/connections-setup.tsx`: + +```typescript +import { useState } from "react"; +import { SlotCard } from "./slot-card"; +import type { ConnectionSlot } from "./use-slot-resolution"; + +export interface ConnectionsSetupProps { + slots: Record; + onComplete: (connections: Record) => void; +} + +export function ConnectionsSetup({ slots, onComplete }: ConnectionsSetupProps) { + const [completed, setCompleted] = useState>({}); + + const handleSlotComplete = (slotId: string, connectionId: string) => { + const next = { ...completed }; + if (connectionId === "") { + delete next[slotId]; + } else { + next[slotId] = connectionId; + } + setCompleted(next); + + const slotIds = Object.keys(slots); + const allDone = slotIds.every((id) => next[id]); + if (allDone) { + onComplete(next); + } + }; + + return ( +
+ {Object.entries(slots).map(([slotId, slot]) => ( + handleSlotComplete(slotId, connectionId)} + /> + ))} +
+ ); +} +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/connections-setup.tsx +git commit -m "feat(connections-setup): add root connections-setup component" +``` + +--- + +## Task 11: `index.ts` barrel export + +**Files:** +- Create: `apps/mesh/src/web/components/connections-setup/index.ts` + +**Step 1: Write the barrel** + +Create `apps/mesh/src/web/components/connections-setup/index.ts`: + +```typescript +export { ConnectionsSetup } from "./connections-setup"; +export type { ConnectionsSetupProps } from "./connections-setup"; +export type { ConnectionSlot } from "./use-slot-resolution"; +``` + +**Step 2: Format and commit** + +```bash +bun run fmt +git add apps/mesh/src/web/components/connections-setup/index.ts +git commit -m "feat(connections-setup): add barrel export" +``` + +--- + +## Task 12: Verify full implementation + +**Step 1: Run all tests** + +```bash +bun test apps/mesh/src/web/components/connections-setup/ +``` + +Expected: PASS (slot-resolution tests). + +**Step 2: Type-check** + +```bash +bun run check +``` + +Expected: no errors in the new files. + +**Step 3: Lint** + +```bash +bun run lint +``` + +Fix any reported issues (kebab-case filenames, query key constants, no useEffect). + +**Step 4: Format** + +```bash +bun run fmt +``` + +--- + +## TODO (deferred) + +- **CONFIG phase**: For MCPs with a `configuration_state` schema (configurable MCPs), add a config form phase after AUTH. Reuse `MCPConfigurationForm` from `apps/mesh/src/web/components/details/connection/settings-tab/mcp-configuration-form.tsx`. Trigger this phase when `isActive && connection.configuration_state !== null && configFormNotYetSubmitted`. + +- **PICKER satisfied-connection preselection**: When the DONE state is entered from a PICKER selection of an already-active connection, `SlotDone` should show that connection without going through polling. From 1eefc72c300e3b2fc3c3fc332106da74e40ddcf2 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:20:15 -0300 Subject: [PATCH 03/21] feat(connections-setup): add registry-item and connection-poll query keys --- apps/mesh/src/web/lib/query-keys.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/mesh/src/web/lib/query-keys.ts b/apps/mesh/src/web/lib/query-keys.ts index 20a38913a7..a54ef3b238 100644 --- a/apps/mesh/src/web/lib/query-keys.ts +++ b/apps/mesh/src/web/lib/query-keys.ts @@ -49,6 +49,12 @@ export const KEYS = { isMCPAuthenticated: (url: string, token: string | null) => ["is-mcp-authenticated", url, token] as const, + registryItem: (registryId: string, itemId: string) => + ["registry-item", registryId, itemId] as const, + + connectionPoll: (connectionId: string) => + ["connection-poll", connectionId] as const, + // MCP tools (scoped by URL and optional token) mcpTools: (url: string, token?: string | null) => ["mcp", "tools", url, token] as const, From 6ee122578190bf4b7f614d8818b10dc9e550d3b5 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:23:15 -0300 Subject: [PATCH 04/21] feat(connections-setup): add slot resolution pure logic with tests Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../connections-setup/slot-resolution.test.ts | 92 +++++++++++++++++++ .../connections-setup/slot-resolution.ts | 31 +++++++ 2 files changed, 123 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts create mode 100644 apps/mesh/src/web/components/connections-setup/slot-resolution.ts diff --git a/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts b/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts new file mode 100644 index 0000000000..c54cbebf96 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "bun:test"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { resolveInitialPhase } from "./slot-resolution"; + +function makeConn( + overrides: Partial & { metadata?: Record }, +): ConnectionEntity { + return { + id: "conn_test", + title: "Test", + status: "inactive", + connection_type: "HTTP", + connection_url: null, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + description: null, + icon: null, + app_name: null, + app_id: null, + tools: null, + bindings: null, + organization_id: "org_1", + created_by: "user_1", + updated_by: "user_1", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + } as unknown as ConnectionEntity; +} + +describe("resolveInitialPhase", () => { + it("returns 'install' when no matching connections exist", () => { + const connections: ConnectionEntity[] = [ + makeConn({ metadata: { registry_item_id: "other-item" } }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("install"); + }); + + it("returns 'done' when a matching active connection exists", () => { + const connections: ConnectionEntity[] = [ + makeConn({ + id: "conn_active", + status: "active", + metadata: { registry_item_id: "my-item" }, + }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("done"); + }); + + it("returns 'picker' when matching connections exist but none are active", () => { + const connections: ConnectionEntity[] = [ + makeConn({ + id: "conn_inactive", + status: "inactive", + metadata: { registry_item_id: "my-item" }, + }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("picker"); + }); + + it("returns 'done' for first active match when multiple exist", () => { + const connections: ConnectionEntity[] = [ + makeConn({ + status: "inactive", + metadata: { registry_item_id: "my-item" }, + }), + makeConn({ + id: "conn_2", + status: "active", + metadata: { registry_item_id: "my-item" }, + }), + ]; + expect(resolveInitialPhase(connections, "my-item")).toBe("done"); + }); +}); + +describe("findMatchingConnections", () => { + it("filters connections by registry_item_id", async () => { + const connections: ConnectionEntity[] = [ + makeConn({ id: "conn_a", metadata: { registry_item_id: "item-1" } }), + makeConn({ id: "conn_b", metadata: { registry_item_id: "item-2" } }), + makeConn({ id: "conn_c", metadata: { registry_item_id: "item-1" } }), + ]; + const { findMatchingConnections } = await import("./slot-resolution"); + const result = findMatchingConnections(connections, "item-1"); + expect(result).toHaveLength(2); + expect(result.map((c) => c.id)).toEqual(["conn_a", "conn_c"]); + }); +}); diff --git a/apps/mesh/src/web/components/connections-setup/slot-resolution.ts b/apps/mesh/src/web/components/connections-setup/slot-resolution.ts new file mode 100644 index 0000000000..834148f00e --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-resolution.ts @@ -0,0 +1,31 @@ +import type { ConnectionEntity } from "@decocms/mesh-sdk"; + +export type SlotPhase = + | "loading" + | "picker" + | "install" + | "polling" + | "auth-oauth" + | "auth-token" + | "done"; + +export function findMatchingConnections( + connections: ConnectionEntity[], + itemId: string, +): ConnectionEntity[] { + return connections.filter( + (c) => + (c.metadata as Record | null)?.registry_item_id === + itemId, + ); +} + +export function resolveInitialPhase( + connections: ConnectionEntity[], + itemId: string, +): "done" | "picker" | "install" { + const matches = findMatchingConnections(connections, itemId); + if (matches.length === 0) return "install"; + const hasActive = matches.some((c) => c.status === "active"); + return hasActive ? "done" : "picker"; +} From 64e9adcca1e7051522ec566d6473b385946079ea Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:24:44 -0300 Subject: [PATCH 05/21] fix(connections-setup): replace dynamic import with static import in slot-resolution tests Removes the unnecessary `await import()` in the `findMatchingConnections` test that caused TS errors (module-not-found and implicit-any on the map callback). `findMatchingConnections` is now included in the top-level static import alongside `resolveInitialPhase`. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../components/connections-setup/slot-resolution.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts b/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts index c54cbebf96..7cb8c8be5e 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts +++ b/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "bun:test"; import type { ConnectionEntity } from "@decocms/mesh-sdk"; -import { resolveInitialPhase } from "./slot-resolution"; +import { + findMatchingConnections, + resolveInitialPhase, +} from "./slot-resolution"; function makeConn( overrides: Partial & { metadata?: Record }, @@ -78,13 +81,12 @@ describe("resolveInitialPhase", () => { }); describe("findMatchingConnections", () => { - it("filters connections by registry_item_id", async () => { + it("filters connections by registry_item_id", () => { const connections: ConnectionEntity[] = [ makeConn({ id: "conn_a", metadata: { registry_item_id: "item-1" } }), makeConn({ id: "conn_b", metadata: { registry_item_id: "item-2" } }), makeConn({ id: "conn_c", metadata: { registry_item_id: "item-1" } }), ]; - const { findMatchingConnections } = await import("./slot-resolution"); const result = findMatchingConnections(connections, "item-1"); expect(result).toHaveLength(2); expect(result.map((c) => c.id)).toEqual(["conn_a", "conn_c"]); From 089de1b3a95867009371189fe96db350c4072095 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:34:49 -0300 Subject: [PATCH 06/21] fix(connections-setup): fix misleading test description in slot-resolution --- .../web/components/connections-setup/slot-resolution.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts b/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts index 7cb8c8be5e..33851cff3c 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts +++ b/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts @@ -64,7 +64,7 @@ describe("resolveInitialPhase", () => { expect(resolveInitialPhase(connections, "my-item")).toBe("picker"); }); - it("returns 'done' for first active match when multiple exist", () => { + it("returns 'done' when at least one active match exists among multiple", () => { const connections: ConnectionEntity[] = [ makeConn({ status: "inactive", From 6d768c6c2baaa9142615951df211c039c7366551 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:36:40 -0300 Subject: [PATCH 07/21] feat(connections-setup): add use-slot-resolution hook --- .../connections-setup/use-slot-resolution.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts diff --git a/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts b/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts new file mode 100644 index 0000000000..71d8d10763 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts @@ -0,0 +1,96 @@ +import { useQuery } from "@tanstack/react-query"; +import { + useConnections, + useProjectContext, + type ConnectionEntity, +} from "@decocms/mesh-sdk"; +import { useRegistryConnections } from "@/web/hooks/use-binding"; +import { + callRegistryTool, + extractItemsFromResponse, + findListToolName, +} from "@/web/utils/registry-utils"; +import { KEYS } from "@/web/lib/query-keys"; +import type { RegistryItem } from "@/web/components/store/types"; +import { + findMatchingConnections, + resolveInitialPhase, + type SlotPhase, +} from "./slot-resolution"; + +export interface ConnectionSlot { + label: string; + registry: string; + item_id: string; +} + +export interface SlotResolution { + initialPhase: SlotPhase; + registryItem: RegistryItem | null; + matchingConnections: ConnectionEntity[]; + satisfiedConnection: ConnectionEntity | null; + isLoading: boolean; + registryError: string | null; +} + +export function useSlotResolution(slot: ConnectionSlot): SlotResolution { + const { org } = useProjectContext(); + const allConnections = useConnections(); + const registryConnections = useRegistryConnections(allConnections); + + const registryConn = registryConnections.find( + (c) => c.id === slot.registry || c.app_name === slot.registry, + ); + + const { data: registryItem, isLoading: isLoadingItem } = useQuery({ + queryKey: KEYS.registryItem(slot.registry, slot.item_id), + queryFn: async (): Promise => { + if (!registryConn) return null; + const listTool = findListToolName(registryConn.tools); + if (!listTool) return null; + const result = await callRegistryTool( + registryConn.id, + org.id, + listTool, + { where: { id: slot.item_id } }, + ); + const items = extractItemsFromResponse(result); + return items[0] ?? null; + }, + enabled: Boolean(registryConn && org), + staleTime: 60 * 60 * 1000, + }); + + const connections = allConnections ?? []; + const matchingConnections = findMatchingConnections( + connections, + slot.item_id, + ); + const satisfiedConnection = + matchingConnections.find((c) => c.status === "active") ?? null; + + if (!allConnections || isLoadingItem) { + return { + initialPhase: "loading", + registryItem: null, + matchingConnections: [], + satisfiedConnection: null, + isLoading: true, + registryError: null, + }; + } + + const registryError = + !isLoadingItem && !registryItem ? "Registry item not found." : null; + + const initialPhase = resolveInitialPhase(connections, slot.item_id); + + return { + initialPhase, + registryItem: registryItem ?? null, + matchingConnections, + satisfiedConnection, + isLoading: false, + registryError, + }; +} From 4f8b36bd9f85d6144793f558bc3099ec72f8db39 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:40:45 -0300 Subject: [PATCH 08/21] fix(connections-setup): fix dead null-check and improve registry error messages --- .../connections-setup/use-slot-resolution.ts | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts b/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts index 71d8d10763..fa2c0eae20 100644 --- a/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts +++ b/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts @@ -61,15 +61,7 @@ export function useSlotResolution(slot: ConnectionSlot): SlotResolution { staleTime: 60 * 60 * 1000, }); - const connections = allConnections ?? []; - const matchingConnections = findMatchingConnections( - connections, - slot.item_id, - ); - const satisfiedConnection = - matchingConnections.find((c) => c.status === "active") ?? null; - - if (!allConnections || isLoadingItem) { + if (isLoadingItem) { return { initialPhase: "loading", registryItem: null, @@ -80,10 +72,20 @@ export function useSlotResolution(slot: ConnectionSlot): SlotResolution { }; } - const registryError = - !isLoadingItem && !registryItem ? "Registry item not found." : null; + const matchingConnections = findMatchingConnections( + allConnections, + slot.item_id, + ); + const satisfiedConnection = + matchingConnections.find((c) => c.status === "active") ?? null; + + const registryError = !registryConn + ? "Registry connection not found." + : !registryItem + ? "Registry item not found." + : null; - const initialPhase = resolveInitialPhase(connections, slot.item_id); + const initialPhase = resolveInitialPhase(allConnections, slot.item_id); return { initialPhase, From 9bec0c4aad17550e1a7e77a1624868ea9d162bd0 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:41:54 -0300 Subject: [PATCH 09/21] feat(connections-setup): add use-connection-poller hook --- .../use-connection-poller.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/use-connection-poller.ts diff --git a/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts b/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts new file mode 100644 index 0000000000..8324ca8a54 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts @@ -0,0 +1,82 @@ +import { useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + createMCPClient, + SELF_MCP_ALIAS_ID, + useProjectContext, + type ConnectionEntity, +} from "@decocms/mesh-sdk"; +import { KEYS } from "@/web/lib/query-keys"; + +const POLL_INTERVAL_MS = 2000; +const POLL_TIMEOUT_MS = 15000; + +export interface ConnectionPollerResult { + connection: ConnectionEntity | null; + isActive: boolean; + isTimedOut: boolean; + isPolling: boolean; +} + +export function useConnectionPoller( + connectionId: string | null, +): ConnectionPollerResult { + const { org } = useProjectContext(); + const startTimeRef = useRef(0); + + if (connectionId && startTimeRef.current === 0) { + startTimeRef.current = Date.now(); + } + if (!connectionId) { + startTimeRef.current = 0; + } + + const { data: connection } = useQuery({ + queryKey: KEYS.connectionPoll(connectionId ?? ""), + queryFn: async (): Promise => { + if (!connectionId) return null; + const client = await createMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + try { + const result = (await client.callTool({ + name: "COLLECTION_CONNECTIONS_GET", + arguments: { id: connectionId }, + })) as { structuredContent?: ConnectionEntity } | ConnectionEntity; + return ( + (result as { structuredContent?: ConnectionEntity }) + .structuredContent ?? (result as ConnectionEntity) + ); + } finally { + await client.close().catch(console.error); + } + }, + refetchInterval: (query) => { + const conn = query.state.data; + if (!connectionId) return false; + if (conn?.status === "active" || conn?.status === "error") return false; + if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) return false; + return POLL_INTERVAL_MS; + }, + enabled: Boolean(connectionId && org), + staleTime: 0, + }); + + const isTimedOut = + Boolean(connectionId) && + startTimeRef.current > 0 && + Date.now() - startTimeRef.current > POLL_TIMEOUT_MS && + connection?.status !== "active"; + + return { + connection: connection ?? null, + isActive: connection?.status === "active", + isTimedOut, + isPolling: + Boolean(connectionId) && + connection?.status !== "active" && + connection?.status !== "error" && + !isTimedOut, + }; +} From f903a8afcbcb2a3532b9cd6be4e2f146be63c243 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:44:09 -0300 Subject: [PATCH 10/21] feat(connections-setup): add slot-done component --- .../connections-setup/slot-done.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/slot-done.tsx diff --git a/apps/mesh/src/web/components/connections-setup/slot-done.tsx b/apps/mesh/src/web/components/connections-setup/slot-done.tsx new file mode 100644 index 0000000000..1fbceecd8c --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-done.tsx @@ -0,0 +1,31 @@ +import { CheckCircle, ChevronDown } from "lucide-react"; +import { Button } from "@deco/ui/components/button.tsx"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; + +interface SlotDoneProps { + label: string; + connection: ConnectionEntity; + onReset: () => void; +} + +export function SlotDone({ label, connection, onReset }: SlotDoneProps) { + return ( +
+ +
+

{label}

+

+ {connection.title} +

+
+ +
+ ); +} From c992085813efa43a7f7ad6da05f77e84cfabfdb1 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:44:43 -0300 Subject: [PATCH 11/21] feat(connections-setup): add slot-install-form component --- .../connections-setup/slot-install-form.tsx | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/slot-install-form.tsx diff --git a/apps/mesh/src/web/components/connections-setup/slot-install-form.tsx b/apps/mesh/src/web/components/connections-setup/slot-install-form.tsx new file mode 100644 index 0000000000..25360bc194 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-install-form.tsx @@ -0,0 +1,88 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + useConnectionActions, + useProjectContext, + type ConnectionEntity, +} from "@decocms/mesh-sdk"; +import { authClient } from "@/web/lib/auth-client"; +import { extractConnectionData } from "@/web/utils/extract-connection-data"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@deco/ui/components/form.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; +import type { RegistryItem } from "@/web/components/store/types"; + +const installSchema = z.object({ + title: z.string().min(1, "Name is required"), +}); + +type InstallFormData = z.infer; + +interface SlotInstallFormProps { + registryItem: RegistryItem; + onInstalled: (connectionId: string) => void; +} + +export function SlotInstallForm({ + registryItem, + onInstalled, +}: SlotInstallFormProps) { + const { org } = useProjectContext(); + const { data: session } = authClient.useSession(); + const actions = useConnectionActions(); + + const connectionData = extractConnectionData( + registryItem, + org.id, + session?.user?.id ?? "system", + ); + + const form = useForm({ + resolver: zodResolver(installSchema), + defaultValues: { title: connectionData.title ?? "" }, + }); + + const handleSubmit = async (data: InstallFormData) => { + const payload: ConnectionEntity = { + ...(connectionData as ConnectionEntity), + title: data.title, + }; + await actions.create.mutateAsync(payload); + onInstalled(connectionData.id); + }; + + return ( +
+ + ( + + Connection name + + + + + + )} + /> + + + + ); +} From 722bcc203257d93bbfe8493a4530baaa570bebd6 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:45:18 -0300 Subject: [PATCH 12/21] feat(connections-setup): add slot-auth-oauth component --- .../connections-setup/slot-auth-oauth.tsx | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx diff --git a/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx b/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx new file mode 100644 index 0000000000..b7137e2330 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { authenticateMcp, useConnectionActions } from "@decocms/mesh-sdk"; +import { useQueryClient } from "@tanstack/react-query"; +import { KEYS } from "@/web/lib/query-keys"; +import { Button } from "@deco/ui/components/button.tsx"; + +interface SlotAuthOAuthProps { + connectionId: string; + providerName: string; + onAuthed: () => void; +} + +export function SlotAuthOAuth({ + connectionId, + providerName, + onAuthed, +}: SlotAuthOAuthProps) { + const [isPending, setIsPending] = useState(false); + const actions = useConnectionActions(); + const queryClient = useQueryClient(); + const mcpProxyUrl = new URL(`/mcp/${connectionId}`, window.location.origin); + + const handleAuthorize = async () => { + setIsPending(true); + try { + const { token, tokenInfo, error } = await authenticateMcp({ + connectionId, + }); + + if (error || !token) { + toast.error(`Authorization failed: ${error ?? "Unknown error"}`); + return; + } + + if (tokenInfo) { + const response = await fetch( + `/api/connections/${connectionId}/oauth-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + accessToken: tokenInfo.accessToken, + refreshToken: tokenInfo.refreshToken, + expiresIn: tokenInfo.expiresIn, + scope: tokenInfo.scope, + clientId: tokenInfo.clientId, + clientSecret: tokenInfo.clientSecret, + tokenEndpoint: tokenInfo.tokenEndpoint, + }), + }, + ); + if (!response.ok) { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } else { + // Trigger tool re-discovery + await actions.update.mutateAsync({ id: connectionId, data: {} }); + } + } else { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } + + await queryClient.invalidateQueries({ + queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), + }); + + onAuthed(); + } finally { + setIsPending(false); + } + }; + + return ( +
+

+ Authorize Mesh to access {providerName} on your behalf. +

+ +
+ ); +} From d94b896bd64228ea18eec5542c6443342e7fc3d8 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:45:52 -0300 Subject: [PATCH 13/21] feat(connections-setup): add slot-auth-token component --- .../connections-setup/slot-auth-token.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/slot-auth-token.tsx diff --git a/apps/mesh/src/web/components/connections-setup/slot-auth-token.tsx b/apps/mesh/src/web/components/connections-setup/slot-auth-token.tsx new file mode 100644 index 0000000000..b9e63baae0 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-auth-token.tsx @@ -0,0 +1,71 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useConnectionActions } from "@decocms/mesh-sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@deco/ui/components/form.tsx"; +import { Input } from "@deco/ui/components/input.tsx"; + +const tokenSchema = z.object({ + token: z.string().min(1, "Token is required"), +}); + +type TokenFormData = z.infer; + +interface SlotAuthTokenProps { + connectionId: string; + onAuthed: () => void; +} + +export function SlotAuthToken({ connectionId, onAuthed }: SlotAuthTokenProps) { + const actions = useConnectionActions(); + + const form = useForm({ + resolver: zodResolver(tokenSchema), + defaultValues: { token: "" }, + }); + + const handleSubmit = async (data: TokenFormData) => { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: data.token }, + }); + // Trigger tool re-discovery + await actions.update.mutateAsync({ id: connectionId, data: {} }); + onAuthed(); + }; + + return ( +
+ + ( + + API Token + + + + + + )} + /> + + + + ); +} From cb6a7be2df4b00e68b1da99afd517745a1117999 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:52:45 -0300 Subject: [PATCH 14/21] fix(connections-setup): fix poller response shape, install ID, oauth error handling, icon source --- .../connections-setup/slot-auth-oauth.tsx | 51 +++++++++++-------- .../connections-setup/slot-done.tsx | 2 +- .../connections-setup/slot-install-form.tsx | 4 +- .../use-connection-poller.ts | 7 +-- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx b/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx index b7137e2330..303bab4844 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx +++ b/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx @@ -34,31 +34,39 @@ export function SlotAuthOAuth({ } if (tokenInfo) { - const response = await fetch( - `/api/connections/${connectionId}/oauth-token`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - accessToken: tokenInfo.accessToken, - refreshToken: tokenInfo.refreshToken, - expiresIn: tokenInfo.expiresIn, - scope: tokenInfo.scope, - clientId: tokenInfo.clientId, - clientSecret: tokenInfo.clientSecret, - tokenEndpoint: tokenInfo.tokenEndpoint, - }), - }, - ); - if (!response.ok) { + try { + const response = await fetch( + `/api/connections/${connectionId}/oauth-token`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + accessToken: tokenInfo.accessToken, + refreshToken: tokenInfo.refreshToken, + expiresIn: tokenInfo.expiresIn, + scope: tokenInfo.scope, + clientId: tokenInfo.clientId, + clientSecret: tokenInfo.clientSecret, + tokenEndpoint: tokenInfo.tokenEndpoint, + }), + }, + ); + if (!response.ok) { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } else { + // Trigger tool re-discovery + await actions.update.mutateAsync({ id: connectionId, data: {} }); + } + } catch (err) { + console.error("Error saving OAuth token:", err); await actions.update.mutateAsync({ id: connectionId, data: { connection_token: token }, }); - } else { - // Trigger tool re-discovery - await actions.update.mutateAsync({ id: connectionId, data: {} }); } } else { await actions.update.mutateAsync({ @@ -71,6 +79,7 @@ export function SlotAuthOAuth({ queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), }); + toast.success("Authorization successful"); onAuthed(); } finally { setIsPending(false); diff --git a/apps/mesh/src/web/components/connections-setup/slot-done.tsx b/apps/mesh/src/web/components/connections-setup/slot-done.tsx index 1fbceecd8c..333e81f9d7 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-done.tsx +++ b/apps/mesh/src/web/components/connections-setup/slot-done.tsx @@ -1,4 +1,4 @@ -import { CheckCircle, ChevronDown } from "lucide-react"; +import { CheckCircle, ChevronDown } from "@untitledui/icons"; import { Button } from "@deco/ui/components/button.tsx"; import type { ConnectionEntity } from "@decocms/mesh-sdk"; diff --git a/apps/mesh/src/web/components/connections-setup/slot-install-form.tsx b/apps/mesh/src/web/components/connections-setup/slot-install-form.tsx index 25360bc194..deac7ab8a8 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-install-form.tsx +++ b/apps/mesh/src/web/components/connections-setup/slot-install-form.tsx @@ -55,8 +55,8 @@ export function SlotInstallForm({ ...(connectionData as ConnectionEntity), title: data.title, }; - await actions.create.mutateAsync(payload); - onInstalled(connectionData.id); + const created = await actions.create.mutateAsync(payload); + onInstalled(created.id); }; return ( diff --git a/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts b/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts index 8324ca8a54..b98b7e002d 100644 --- a/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts +++ b/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts @@ -43,11 +43,8 @@ export function useConnectionPoller( const result = (await client.callTool({ name: "COLLECTION_CONNECTIONS_GET", arguments: { id: connectionId }, - })) as { structuredContent?: ConnectionEntity } | ConnectionEntity; - return ( - (result as { structuredContent?: ConnectionEntity }) - .structuredContent ?? (result as ConnectionEntity) - ); + })) as { structuredContent?: { item: ConnectionEntity | null } }; + return result.structuredContent?.item ?? null; } finally { await client.close().catch(console.error); } From 023147f7ba995e8b7e33bc2ec39d29daac650b82 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 16:55:02 -0300 Subject: [PATCH 15/21] feat(connections-setup): add slot-card state machine component Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../connections-setup/slot-card.tsx | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 apps/mesh/src/web/components/connections-setup/slot-card.tsx diff --git a/apps/mesh/src/web/components/connections-setup/slot-card.tsx b/apps/mesh/src/web/components/connections-setup/slot-card.tsx new file mode 100644 index 0000000000..d098182b59 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-card.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { AlertCircle, Loader2 } from "lucide-react"; +import { + isConnectionAuthenticated, + type ConnectionEntity, +} from "@decocms/mesh-sdk"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useSlotResolution, type ConnectionSlot } from "./use-slot-resolution"; +import { useConnectionPoller } from "./use-connection-poller"; +import { type SlotPhase } from "./slot-resolution"; +import { SlotDone } from "./slot-done"; +import { SlotInstallForm } from "./slot-install-form"; +import { SlotAuthOAuth } from "./slot-auth-oauth"; +import { SlotAuthToken } from "./slot-auth-token"; + +interface SlotCardProps { + slot: ConnectionSlot; + onComplete: (connectionId: string) => void; +} + +export function SlotCard({ slot, onComplete }: SlotCardProps) { + const resolution = useSlotResolution(slot); + const [phase, setPhase] = useState(null); + const [pollingConnectionId, setPollingConnectionId] = useState( + null, + ); + const [selectedConnection, setSelectedConnection] = + useState(null); + + const poller = useConnectionPoller(pollingConnectionId); + + // Derive effective phase: explicit override takes priority, else from resolution + const effectivePhase: SlotPhase = phase ?? resolution.initialPhase; + + // React to poller becoming active + if (pollingConnectionId && poller.isActive && poller.connection) { + setPollingConnectionId(null); + setSelectedConnection(poller.connection); + setPhase("done"); + onComplete(poller.connection.id); + } + + // React to poller timeout/error — determine auth type needed + if ( + pollingConnectionId && + (poller.isTimedOut || poller.connection?.status === "error") + ) { + const connectionId = pollingConnectionId; + setPollingConnectionId(null); + + // Async: check auth status to determine next phase + const url = new URL(`/mcp/${connectionId}`, window.location.origin).href; + isConnectionAuthenticated({ url, token: null }).then((authStatus) => { + if (authStatus.supportsOAuth) { + setPhase("auth-oauth"); + } else { + setPhase("auth-token"); + } + }); + } + + const handleInstalled = (connectionId: string) => { + setPollingConnectionId(connectionId); + setPhase("polling"); + }; + + const handleAuthed = () => { + const id = pollingConnectionId ?? selectedConnection?.id ?? null; + if (id) { + setPollingConnectionId(id); + setPhase("polling"); + } + }; + + const handleReset = () => { + const hasExisting = resolution.matchingConnections.length > 0; + setPhase(hasExisting ? "picker" : "install"); + setSelectedConnection(null); + setPollingConnectionId(null); + onComplete(""); + }; + + const resolvedConnection = + selectedConnection ?? resolution.satisfiedConnection ?? null; + + if (effectivePhase === "loading") { + return ( +
+ +

{slot.label}

+
+ ); + } + + if (resolution.registryError) { + return ( +
+ +
+

{slot.label}

+

+ {resolution.registryError} +

+
+
+ ); + } + + return ( +
+

{slot.label}

+ + {effectivePhase === "done" && resolvedConnection && ( + + )} + + {effectivePhase === "picker" && ( +
+

Already installed:

+
+ {resolution.matchingConnections.map((conn) => ( + + ))} +
+ +
+ )} + + {effectivePhase === "install" && resolution.registryItem && ( + + )} + + {effectivePhase === "polling" && ( +
+ + Connecting... +
+ )} + + {effectivePhase === "auth-oauth" && selectedConnection && ( + + )} + + {effectivePhase === "auth-token" && selectedConnection && ( + + )} +
+ ); +} From 81c3f530e5f45e9d2166b2ea4260a1b6e2485c0d Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Mon, 23 Feb 2026 17:31:28 -0300 Subject: [PATCH 16/21] feat(connections-setup): fix auth phase bug, add root component and barrel export --- .../connections-setup/connections-setup.tsx | 41 +++++++++++++++++++ .../web/components/connections-setup/index.ts | 3 ++ .../connections-setup/slot-card.tsx | 22 ++++++---- 3 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 apps/mesh/src/web/components/connections-setup/connections-setup.tsx create mode 100644 apps/mesh/src/web/components/connections-setup/index.ts diff --git a/apps/mesh/src/web/components/connections-setup/connections-setup.tsx b/apps/mesh/src/web/components/connections-setup/connections-setup.tsx new file mode 100644 index 0000000000..0282b80f45 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/connections-setup.tsx @@ -0,0 +1,41 @@ +import { useState } from "react"; +import { SlotCard } from "./slot-card"; +import type { ConnectionSlot } from "./use-slot-resolution"; + +export interface ConnectionsSetupProps { + slots: Record; + onComplete: (connections: Record) => void; +} + +export function ConnectionsSetup({ slots, onComplete }: ConnectionsSetupProps) { + const [completed, setCompleted] = useState>({}); + + const handleSlotComplete = (slotId: string, connectionId: string) => { + const next = { ...completed }; + if (connectionId === "") { + delete next[slotId]; + } else { + next[slotId] = connectionId; + } + setCompleted(next); + + const allDone = Object.keys(slots).every((id) => next[id]); + if (allDone) { + onComplete(next); + } + }; + + return ( +
+ {Object.entries(slots).map(([slotId, slot]) => ( + + handleSlotComplete(slotId, connectionId) + } + /> + ))} +
+ ); +} diff --git a/apps/mesh/src/web/components/connections-setup/index.ts b/apps/mesh/src/web/components/connections-setup/index.ts new file mode 100644 index 0000000000..52e411d0bd --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/index.ts @@ -0,0 +1,3 @@ +export { ConnectionsSetup } from "./connections-setup"; +export type { ConnectionsSetupProps } from "./connections-setup"; +export type { ConnectionSlot } from "./use-slot-resolution"; diff --git a/apps/mesh/src/web/components/connections-setup/slot-card.tsx b/apps/mesh/src/web/components/connections-setup/slot-card.tsx index d098182b59..fa03f4d301 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-card.tsx +++ b/apps/mesh/src/web/components/connections-setup/slot-card.tsx @@ -46,17 +46,21 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { (poller.isTimedOut || poller.connection?.status === "error") ) { const connectionId = pollingConnectionId; + // Capture the fetched connection entity so auth phases can use its id + if (poller.connection) setSelectedConnection(poller.connection); setPollingConnectionId(null); // Async: check auth status to determine next phase const url = new URL(`/mcp/${connectionId}`, window.location.origin).href; - isConnectionAuthenticated({ url, token: null }).then((authStatus) => { - if (authStatus.supportsOAuth) { - setPhase("auth-oauth"); - } else { - setPhase("auth-token"); - } - }); + isConnectionAuthenticated({ url, token: null }) + .then((authStatus) => { + if (authStatus.supportsOAuth) { + setPhase("auth-oauth"); + } else { + setPhase("auth-token"); + } + }) + .catch(() => setPhase("auth-token")); } const handleInstalled = (connectionId: string) => { @@ -108,7 +112,9 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { return (
-

{slot.label}

+ {effectivePhase !== "done" && ( +

{slot.label}

+ )} {effectivePhase === "done" && resolvedConnection && ( Date: Tue, 24 Feb 2026 12:07:06 -0300 Subject: [PATCH 17/21] fix(connections-setup): fix render-phase side effects and stuck auth state - Move isConnectionAuthenticated out of render body into useQuery (authCheckId state triggers the query after poller timeout/error) - Add completedIdRef guard so onComplete fires exactly once per connection even under concurrent-mode re-renders - Fix stuck UI when poller.connection is null on timeout: auth phases now fall back to authCheckId for the connection ID - Use functional setCompleted updater in ConnectionsSetup to avoid stale closure on rapid slot completions; move allDone/onComplete check to render with a wasAllDoneRef guard - Reset startTimeRef in useConnectionPoller whenever connectionId changes (not only when it was previously 0) to prevent a new connection inheriting a stale start time Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../connections-setup/connections-setup.tsx | 35 +++++---- .../connections-setup/slot-card.tsx | 72 ++++++++++++------- .../use-connection-poller.ts | 9 ++- 3 files changed, 74 insertions(+), 42 deletions(-) diff --git a/apps/mesh/src/web/components/connections-setup/connections-setup.tsx b/apps/mesh/src/web/components/connections-setup/connections-setup.tsx index 0282b80f45..ea9166eae4 100644 --- a/apps/mesh/src/web/components/connections-setup/connections-setup.tsx +++ b/apps/mesh/src/web/components/connections-setup/connections-setup.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { SlotCard } from "./slot-card"; import type { ConnectionSlot } from "./use-slot-resolution"; @@ -9,22 +9,31 @@ export interface ConnectionsSetupProps { export function ConnectionsSetup({ slots, onComplete }: ConnectionsSetupProps) { const [completed, setCompleted] = useState>({}); + const wasAllDoneRef = useRef(false); const handleSlotComplete = (slotId: string, connectionId: string) => { - const next = { ...completed }; - if (connectionId === "") { - delete next[slotId]; - } else { - next[slotId] = connectionId; - } - setCompleted(next); - - const allDone = Object.keys(slots).every((id) => next[id]); - if (allDone) { - onComplete(next); - } + setCompleted((prev) => { + const next = { ...prev }; + if (connectionId === "") { + delete next[slotId]; + } else { + next[slotId] = connectionId; + } + return next; + }); }; + const allSlotIds = Object.keys(slots); + const allDone = + allSlotIds.length > 0 && allSlotIds.every((id) => completed[id]); + + if (allDone && !wasAllDoneRef.current) { + wasAllDoneRef.current = true; + onComplete(completed); + } else if (!allDone) { + wasAllDoneRef.current = false; + } + return (
{Object.entries(slots).map(([slotId, slot]) => ( diff --git a/apps/mesh/src/web/components/connections-setup/slot-card.tsx b/apps/mesh/src/web/components/connections-setup/slot-card.tsx index fa03f4d301..7fe43e26a8 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-card.tsx +++ b/apps/mesh/src/web/components/connections-setup/slot-card.tsx @@ -1,10 +1,12 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { AlertCircle, Loader2 } from "lucide-react"; import { isConnectionAuthenticated, type ConnectionEntity, } from "@decocms/mesh-sdk"; import { Button } from "@deco/ui/components/button.tsx"; +import { KEYS } from "@/web/lib/query-keys"; import { useSlotResolution, type ConnectionSlot } from "./use-slot-resolution"; import { useConnectionPoller } from "./use-connection-poller"; import { type SlotPhase } from "./slot-resolution"; @@ -26,41 +28,56 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { ); const [selectedConnection, setSelectedConnection] = useState(null); + // Tracks which connection needs an auth check after polling times out/errors + const [authCheckId, setAuthCheckId] = useState(null); + // Prevents onComplete from firing more than once per unique connection + const completedIdRef = useRef(null); const poller = useConnectionPoller(pollingConnectionId); + const authUrl = authCheckId + ? new URL(`/mcp/${authCheckId}`, window.location.origin).href + : ""; + + const { data: authStatus } = useQuery({ + queryKey: KEYS.isMCPAuthenticated(authUrl, null), + queryFn: () => isConnectionAuthenticated({ url: authUrl, token: null }), + enabled: Boolean(authCheckId), + staleTime: Infinity, + }); + // Derive effective phase: explicit override takes priority, else from resolution const effectivePhase: SlotPhase = phase ?? resolution.initialPhase; // React to poller becoming active if (pollingConnectionId && poller.isActive && poller.connection) { - setPollingConnectionId(null); - setSelectedConnection(poller.connection); - setPhase("done"); - onComplete(poller.connection.id); + if (completedIdRef.current !== poller.connection.id) { + completedIdRef.current = poller.connection.id; + setPollingConnectionId(null); + setSelectedConnection(poller.connection); + setPhase("done"); + onComplete(poller.connection.id); + } } - // React to poller timeout/error — determine auth type needed + // React to poller timeout/error — queue an auth check instead of firing async in render if ( pollingConnectionId && (poller.isTimedOut || poller.connection?.status === "error") ) { - const connectionId = pollingConnectionId; - // Capture the fetched connection entity so auth phases can use its id if (poller.connection) setSelectedConnection(poller.connection); + setAuthCheckId(pollingConnectionId); setPollingConnectionId(null); + } - // Async: check auth status to determine next phase - const url = new URL(`/mcp/${connectionId}`, window.location.origin).href; - isConnectionAuthenticated({ url, token: null }) - .then((authStatus) => { - if (authStatus.supportsOAuth) { - setPhase("auth-oauth"); - } else { - setPhase("auth-token"); - } - }) - .catch(() => setPhase("auth-token")); + // React to auth check result — set the appropriate auth phase + if ( + authCheckId && + authStatus && + phase !== "auth-oauth" && + phase !== "auth-token" + ) { + setPhase(authStatus.supportsOAuth ? "auth-oauth" : "auth-token"); } const handleInstalled = (connectionId: string) => { @@ -69,8 +86,10 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { }; const handleAuthed = () => { - const id = pollingConnectionId ?? selectedConnection?.id ?? null; + const id = + pollingConnectionId ?? selectedConnection?.id ?? authCheckId ?? null; if (id) { + setAuthCheckId(null); setPollingConnectionId(id); setPhase("polling"); } @@ -81,12 +100,17 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { setPhase(hasExisting ? "picker" : "install"); setSelectedConnection(null); setPollingConnectionId(null); + setAuthCheckId(null); + completedIdRef.current = null; onComplete(""); }; const resolvedConnection = selectedConnection ?? resolution.satisfiedConnection ?? null; + // Fallback to authCheckId when connection entity wasn't fetched before timeout + const authConnectionId = selectedConnection?.id ?? authCheckId; + if (effectivePhase === "loading") { return (
@@ -177,17 +201,17 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) {
)} - {effectivePhase === "auth-oauth" && selectedConnection && ( + {effectivePhase === "auth-oauth" && authConnectionId && ( )} - {effectivePhase === "auth-token" && selectedConnection && ( + {effectivePhase === "auth-token" && authConnectionId && ( )} diff --git a/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts b/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts index b98b7e002d..cc06d1f6c6 100644 --- a/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts +++ b/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts @@ -23,12 +23,11 @@ export function useConnectionPoller( ): ConnectionPollerResult { const { org } = useProjectContext(); const startTimeRef = useRef(0); + const prevConnectionIdRef = useRef(null); - if (connectionId && startTimeRef.current === 0) { - startTimeRef.current = Date.now(); - } - if (!connectionId) { - startTimeRef.current = 0; + if (connectionId !== prevConnectionIdRef.current) { + prevConnectionIdRef.current = connectionId; + startTimeRef.current = connectionId ? Date.now() : 0; } const { data: connection } = useQuery({ From 2cca6eae1a9e11449532cfe54037bad92b1084f8 Mon Sep 17 00:00:00 2001 From: viktormarinho Date: Tue, 24 Feb 2026 17:16:02 -0300 Subject: [PATCH 18/21] test(connections-setup): add DOM component tests with @testing-library/react MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets up happy-dom + RTL infrastructure and adds component-level tests for SlotCard and ConnectionsSetup alongside the existing pure-function tests. Infrastructure: - bunfig.toml: two-stage preload (dom registration before RTL import) - apps/mesh/test-setup.ts: GlobalRegistrator.register({ url: ... }) - apps/mesh/test-setup-rtl.ts: expect.extend(jest-dom matchers) + afterEach cleanup - src/types/bun-testing.d.ts: extends bun:test Matchers with jest-dom types SlotCard tests (slot-card.test.tsx) — 20 tests: - All phase transitions: loading, error, install, polling, done, picker - install → polling → done (calls onComplete once, guarded against re-render duplication) - polling timeout → auth-oauth / auth-token (including null-connection fix) - OAuth/token → back to polling - done → picker / install on Change; picker select active/inactive; Install fresh ConnectionsSetup tests (connections-setup.test.tsx) — 5 tests: - Renders one card per slot - Does not call onComplete until all slots satisfy - Calls onComplete with correct slotId → connectionId map - Does not call onComplete twice on re-renders - Calls onComplete again after reset + re-completion Mocking strategy (Option A): hooks (useSlotResolution, useConnectionPoller) and child components are stubbed so tests cover rendering and state-machine wiring without network calls. connections-setup.test.tsx deliberately does NOT mock ./slot-card to avoid cross-file module registry contamination in Bun 1.3.5. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- apps/mesh/package.json | 5 +- apps/mesh/src/types/bun-testing.d.ts | 14 + .../connections-setup.test.tsx | 288 ++++++++ .../slot-card-transitions.test.ts | 207 ++++++ .../slot-card-transitions.ts | 106 +++ .../connections-setup/slot-card.test.tsx | 632 ++++++++++++++++++ .../connections-setup/slot-card.tsx | 78 ++- .../connections-setup/use-slot-resolution.ts | 2 +- apps/mesh/src/web/routes/orgs/connections.tsx | 44 +- .../web/utils/extract-connection-data.test.ts | 130 ++++ .../src/web/utils/extract-connection-data.ts | 2 +- apps/mesh/test-setup-rtl.ts | 11 + apps/mesh/test-setup.ts | 6 + bunfig.toml | 2 + 14 files changed, 1497 insertions(+), 30 deletions(-) create mode 100644 apps/mesh/src/types/bun-testing.d.ts create mode 100644 apps/mesh/src/web/components/connections-setup/connections-setup.test.tsx create mode 100644 apps/mesh/src/web/components/connections-setup/slot-card-transitions.test.ts create mode 100644 apps/mesh/src/web/components/connections-setup/slot-card-transitions.ts create mode 100644 apps/mesh/src/web/components/connections-setup/slot-card.test.tsx create mode 100644 apps/mesh/src/web/utils/extract-connection-data.test.ts create mode 100644 apps/mesh/test-setup-rtl.ts create mode 100644 apps/mesh/test-setup.ts create mode 100644 bunfig.toml diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 5e4664c68d..99533d5613 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -54,6 +54,7 @@ "@decocms/runtime": "workspace:*", "@decocms/vite-plugin": "workspace:*", "@floating-ui/react": "^0.27.16", + "@happy-dom/global-registrator": "^20.7.0", "@hookform/resolvers": "^5.2.2", "@modelcontextprotocol/sdk": "1.26.0", "@monaco-editor/react": "^4.7.0", @@ -83,6 +84,8 @@ "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query": "5.90.11", "@tanstack/react-router": "^1.139.7", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@tiptap/core": "^3.15.3", "@tiptap/extension-mention": "^3.13.0", "@tiptap/extension-placeholder": "^3.15.3", @@ -113,8 +116,8 @@ "marked": "^15.0.6", "mesh-plugin-object-storage": "workspace:*", "mesh-plugin-private-registry": "workspace:*", - "mesh-plugin-user-sandbox": "workspace:*", "mesh-plugin-reports": "workspace:*", + "mesh-plugin-user-sandbox": "workspace:*", "mesh-plugin-workflows": "workspace:*", "nanoid": "^5.1.6", "pg": "^8.16.3", diff --git a/apps/mesh/src/types/bun-testing.d.ts b/apps/mesh/src/types/bun-testing.d.ts new file mode 100644 index 0000000000..cee12a3835 --- /dev/null +++ b/apps/mesh/src/types/bun-testing.d.ts @@ -0,0 +1,14 @@ +// Extend Bun's test Matchers with @testing-library/jest-dom matchers +// so toBeInTheDocument(), toHaveTextContent(), etc. are properly typed. +import type { TestingLibraryMatchers } from "@testing-library/jest-dom/matchers"; +import type { expect } from "bun:test"; + +export {}; + +declare module "bun:test" { + interface Matchers + extends TestingLibraryMatchers< + ReturnType, + T + > {} +} diff --git a/apps/mesh/src/web/components/connections-setup/connections-setup.test.tsx b/apps/mesh/src/web/components/connections-setup/connections-setup.test.tsx new file mode 100644 index 0000000000..ad19c2c846 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/connections-setup.test.tsx @@ -0,0 +1,288 @@ +/// +/// +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { fireEvent, render, screen } from "@testing-library/react"; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import type { SlotResolution } from "./use-slot-resolution"; +import type { ConnectionPollerResult } from "./use-connection-poller"; + +// ── Module mocks ────────────────────────────────────────────────────────────── +// NOTE: deliberately NOT mocking ./slot-card — ConnectionsSetup is tested with +// the real SlotCard to avoid polluting the shared module registry for slot-card.test.tsx. + +const mockUseSlotResolution = mock(() => makeResolution()); +const mockUseConnectionPoller = mock(() => makePoller()); +const mockIsConnectionAuthenticated = mock(() => + Promise.resolve({ supportsOAuth: false }), +); + +mock.module("./use-slot-resolution", () => ({ + useSlotResolution: mockUseSlotResolution, +})); +mock.module("./use-connection-poller", () => ({ + useConnectionPoller: mockUseConnectionPoller, +})); +mock.module("@decocms/mesh-sdk", () => ({ + isConnectionAuthenticated: mockIsConnectionAuthenticated, +})); + +mock.module("./slot-install-form", () => ({ + SlotInstallForm: ({ onInstalled }: { onInstalled: (id: string) => void }) => + React.createElement( + "button", + { "data-testid": "install-btn", onClick: () => onInstalled("conn_new") }, + "Install", + ), +})); + +mock.module("./slot-done", () => ({ + SlotDone: ({ + label, + connection, + onReset, + }: { + label: string; + connection: ConnectionEntity; + onReset: () => void; + }) => + React.createElement( + "div", + { "data-testid": `done-${label}` }, + React.createElement( + "span", + { "data-testid": `done-title-${label}` }, + connection.title, + ), + React.createElement( + "button", + { "data-testid": `change-${label}`, onClick: onReset }, + "Change", + ), + ), +})); + +mock.module("./slot-auth-oauth", () => ({ + SlotAuthOAuth: ({ onAuthed }: { onAuthed: () => void }) => + React.createElement("button", { onClick: onAuthed }, "Authorize"), +})); + +mock.module("./slot-auth-token", () => ({ + SlotAuthToken: ({ onAuthed }: { onAuthed: () => void }) => + React.createElement("button", { onClick: onAuthed }, "Submit Token"), +})); + +import { ConnectionsSetup } from "./connections-setup"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeConn(overrides: Partial = {}): ConnectionEntity { + return { + id: "conn_test", + title: "Test Connection", + status: "active", + connection_type: "HTTP", + connection_url: null, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + description: null, + icon: null, + app_name: null, + app_id: null, + tools: null, + bindings: null, + organization_id: "org_1", + created_by: "user_1", + updated_by: "user_1", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + } as unknown as ConnectionEntity; +} + +function makeResolution( + overrides: Partial = {}, +): SlotResolution { + return { + initialPhase: "install", + registryItem: { + id: "item_1", + title: "Test MCP", + server: { name: "deco/test" }, + created_at: "", + updated_at: "", + } as SlotResolution["registryItem"], + matchingConnections: [], + satisfiedConnection: null, + isLoading: false, + registryError: null, + ...overrides, + }; +} + +function makePoller( + overrides: Partial = {}, +): ConnectionPollerResult { + return { + connection: null, + isActive: false, + isTimedOut: false, + isPolling: false, + ...overrides, + }; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +const twoSlots = { + model: { + label: "model", + registry: "deco-registry", + item_id: "deco/openrouter", + }, + github: { + label: "github", + registry: "deco-registry", + item_id: "deco/github", + }, +}; + +// Returns picker phase with a unique active connection per slot label so tests +// can click individual slots by text even after re-renders. +function setupPickerPerSlot() { + mockUseSlotResolution.mockImplementation( + // @ts-expect-error — Bun's mock type doesn't infer the slot arg but it is passed at runtime + (slot: ConnectionSlot) => { + const label = slot?.label ?? "unknown"; + return makeResolution({ + initialPhase: "picker", + matchingConnections: [ + makeConn({ + id: `conn_${label}`, + title: `${label}_conn`, + status: "active", + }), + ], + }); + }, + ); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + mockUseSlotResolution.mockReset(); + mockUseConnectionPoller.mockReset(); + mockUseConnectionPoller.mockReturnValue(makePoller()); + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); +}); + +describe("ConnectionsSetup", () => { + it("renders a card for each slot", () => { + render( + React.createElement(ConnectionsSetup, { + slots: twoSlots, + onComplete: () => {}, + }), + { wrapper: createWrapper() }, + ); + + // Both slots in install phase → each shows an install button + expect(screen.getAllByTestId("install-btn").length).toBe(2); + }); + + it("does not call onComplete when only one of two slots is satisfied", () => { + setupPickerPerSlot(); + const onComplete = mock(() => {}); + + render( + React.createElement(ConnectionsSetup, { slots: twoSlots, onComplete }), + { wrapper: createWrapper() }, + ); + + // Complete only the model slot + fireEvent.click(screen.getByText("model_conn")); + + expect(onComplete).not.toHaveBeenCalled(); + }); + + it("calls onComplete with slotId → connectionId map when all slots satisfy", () => { + setupPickerPerSlot(); + const onComplete = mock(() => {}); + + render( + React.createElement(ConnectionsSetup, { slots: twoSlots, onComplete }), + { wrapper: createWrapper() }, + ); + + fireEvent.click(screen.getByText("model_conn")); + fireEvent.click(screen.getByText("github_conn")); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith({ + model: "conn_model", + github: "conn_github", + }); + }); + + it("does not call onComplete a second time on re-renders after completion", () => { + setupPickerPerSlot(); + const onComplete = mock(() => {}); + + const { rerender } = render( + React.createElement(ConnectionsSetup, { slots: twoSlots, onComplete }), + { wrapper: createWrapper() }, + ); + + fireEvent.click(screen.getByText("model_conn")); + fireEvent.click(screen.getByText("github_conn")); + + rerender( + React.createElement(ConnectionsSetup, { slots: twoSlots, onComplete }), + ); + rerender( + React.createElement(ConnectionsSetup, { slots: twoSlots, onComplete }), + ); + + expect(onComplete).toHaveBeenCalledTimes(1); + }); + + it("calls onComplete again after a completed slot is reset and re-satisfied", () => { + setupPickerPerSlot(); + const onComplete = mock(() => {}); + + render( + React.createElement(ConnectionsSetup, { slots: twoSlots, onComplete }), + { wrapper: createWrapper() }, + ); + + // Complete both slots via picker + fireEvent.click(screen.getByText("model_conn")); + fireEvent.click(screen.getByText("github_conn")); + expect(onComplete).toHaveBeenCalledTimes(1); + + // Reset model slot → goes back to picker (matchingConnections still exist) + fireEvent.click(screen.getByTestId("change-model")); + + // Re-pick the active connection from the picker + fireEvent.click(screen.getByText("model_conn")); + + expect(onComplete).toHaveBeenCalledTimes(2); + expect(onComplete).toHaveBeenLastCalledWith({ + model: "conn_model", + github: "conn_github", + }); + }); +}); diff --git a/apps/mesh/src/web/components/connections-setup/slot-card-transitions.test.ts b/apps/mesh/src/web/components/connections-setup/slot-card-transitions.test.ts new file mode 100644 index 0000000000..beacd14bc6 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-card-transitions.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "bun:test"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { + onAuthed, + onInstallFresh, + onInstalled, + onPickActive, + onPickInactive, + onPollerActive, + onPollerTimeout, + onAuthStatus, + onReset, +} from "./slot-card-transitions"; + +function makeConn(overrides: Partial = {}): ConnectionEntity { + return { + id: "conn_test", + title: "Test Connection", + status: "inactive", + connection_type: "HTTP", + connection_url: null, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + description: null, + icon: null, + app_name: null, + app_id: null, + tools: null, + bindings: null, + organization_id: "org_1", + created_by: "user_1", + updated_by: "user_1", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + } as unknown as ConnectionEntity; +} + +describe("onInstalled", () => { + it("transitions to polling phase", () => { + expect(onInstalled("conn_abc").phase).toBe("polling"); + }); + + it("sets pollingConnectionId to the new connection", () => { + expect(onInstalled("conn_abc").pollingConnectionId).toBe("conn_abc"); + }); +}); + +describe("onPollerActive", () => { + it("transitions to done", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + expect(onPollerActive(conn).phase).toBe("done"); + }); + + it("clears pollingConnectionId", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + expect(onPollerActive(conn).pollingConnectionId).toBeNull(); + }); + + it("stores the active connection as selectedConnection", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + expect(onPollerActive(conn).selectedConnection).toBe(conn); + }); +}); + +describe("onPollerTimeout", () => { + it("sets authCheckId to the connectionId so the auth query fires", () => { + const result = onPollerTimeout("conn_abc", makeConn({ id: "conn_abc" })); + expect(result.authCheckId).toBe("conn_abc"); + }); + + it("clears pollingConnectionId", () => { + const result = onPollerTimeout("conn_abc", makeConn()); + expect(result.pollingConnectionId).toBeNull(); + }); + + it("captures the connection entity when the poller fetched it before timing out", () => { + const conn = makeConn({ id: "conn_abc" }); + expect(onPollerTimeout("conn_abc", conn).selectedConnection).toBe(conn); + }); + + it("stores null selectedConnection when the poller never fetched the entity", () => { + expect(onPollerTimeout("conn_abc", null).selectedConnection).toBeNull(); + }); +}); + +describe("onAuthStatus", () => { + it("routes to auth-oauth when the endpoint supports OAuth", () => { + expect(onAuthStatus(true).phase).toBe("auth-oauth"); + }); + + it("routes to auth-token when the endpoint does not support OAuth", () => { + expect(onAuthStatus(false).phase).toBe("auth-token"); + }); +}); + +describe("onAuthed", () => { + it("restarts polling using authCheckId when selectedConnection is null", () => { + const result = onAuthed({ + pollingConnectionId: null, + selectedConnection: null, + authCheckId: "conn_abc", + }); + expect(result.pollingConnectionId).toBe("conn_abc"); + expect(result.authCheckId).toBeNull(); + expect(result.phase).toBe("polling"); + }); + + it("restarts polling using selectedConnection.id as fallback", () => { + const conn = makeConn({ id: "conn_abc" }); + const result = onAuthed({ + pollingConnectionId: null, + selectedConnection: conn, + authCheckId: null, + }); + expect(result.pollingConnectionId).toBe("conn_abc"); + expect(result.phase).toBe("polling"); + }); + + it("prefers pollingConnectionId over selectedConnection.id", () => { + const conn = makeConn({ id: "conn_selected" }); + const result = onAuthed({ + pollingConnectionId: "conn_polling", + selectedConnection: conn, + authCheckId: null, + }); + expect(result.pollingConnectionId).toBe("conn_polling"); + }); + + it("clears authCheckId after auth", () => { + const result = onAuthed({ + pollingConnectionId: null, + selectedConnection: null, + authCheckId: "conn_abc", + }); + expect(result.authCheckId).toBeNull(); + }); + + it("returns empty object when no connection id is available", () => { + const result = onAuthed({ + pollingConnectionId: null, + selectedConnection: null, + authCheckId: null, + }); + expect(result).toEqual({}); + }); +}); + +describe("onReset", () => { + it("goes to picker when existing connections are available", () => { + expect(onReset(true).phase).toBe("picker"); + }); + + it("goes to install when no existing connections", () => { + expect(onReset(false).phase).toBe("install"); + }); + + it("clears selectedConnection", () => { + expect(onReset(false).selectedConnection).toBeNull(); + }); + + it("clears pollingConnectionId", () => { + expect(onReset(false).pollingConnectionId).toBeNull(); + }); + + it("clears authCheckId", () => { + expect(onReset(false).authCheckId).toBeNull(); + }); +}); + +describe("onPickActive", () => { + it("transitions to done", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + expect(onPickActive(conn).phase).toBe("done"); + }); + + it("stores the picked connection", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + expect(onPickActive(conn).selectedConnection).toBe(conn); + }); +}); + +describe("onPickInactive", () => { + it("starts polling for the picked connection", () => { + const conn = makeConn({ id: "conn_abc", status: "inactive" }); + expect(onPickInactive(conn).phase).toBe("polling"); + }); + + it("sets pollingConnectionId to the picked connection id", () => { + const conn = makeConn({ id: "conn_abc", status: "inactive" }); + expect(onPickInactive(conn).pollingConnectionId).toBe("conn_abc"); + }); + + it("stores the picked connection as selectedConnection", () => { + const conn = makeConn({ id: "conn_abc", status: "inactive" }); + expect(onPickInactive(conn).selectedConnection).toBe(conn); + }); +}); + +describe("onInstallFresh", () => { + it("transitions to install phase", () => { + expect(onInstallFresh().phase).toBe("install"); + }); +}); diff --git a/apps/mesh/src/web/components/connections-setup/slot-card-transitions.ts b/apps/mesh/src/web/components/connections-setup/slot-card-transitions.ts new file mode 100644 index 0000000000..455d191468 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-card-transitions.ts @@ -0,0 +1,106 @@ +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import type { SlotPhase } from "./slot-resolution"; + +export type SlotCardState = { + phase: SlotPhase | null; + pollingConnectionId: string | null; + selectedConnection: ConnectionEntity | null; + authCheckId: string | null; +}; + +/** User confirmed the install form — start polling for the new connection. */ +export function onInstalled(connectionId: string): Partial { + return { + pollingConnectionId: connectionId, + phase: "polling", + }; +} + +/** Poller observed the connection is active — mark slot as done. */ +export function onPollerActive( + connection: ConnectionEntity, +): Partial { + return { + pollingConnectionId: null, + selectedConnection: connection, + phase: "done", + }; +} + +/** + * Poller timed out or the connection errored before going active. + * Queue an auth check to determine whether OAuth or token auth is needed. + */ +export function onPollerTimeout( + connectionId: string, + connection: ConnectionEntity | null, +): Partial { + return { + selectedConnection: connection, + authCheckId: connectionId, + pollingConnectionId: null, + }; +} + +/** Auth check completed — transition to the appropriate auth phase. */ +export function onAuthStatus(supportsOAuth: boolean): Partial { + return { + phase: supportsOAuth ? "auth-oauth" : "auth-token", + }; +} + +/** User completed auth — restart polling to verify the connection activates. */ +export function onAuthed( + state: Pick< + SlotCardState, + "pollingConnectionId" | "selectedConnection" | "authCheckId" + >, +): Partial { + const id = + state.pollingConnectionId ?? + state.selectedConnection?.id ?? + state.authCheckId ?? + null; + if (!id) return {}; + return { + authCheckId: null, + pollingConnectionId: id, + phase: "polling", + }; +} + +/** User chose to change the connection — go back to picker or install. */ +export function onReset(hasExisting: boolean): Partial { + return { + phase: hasExisting ? "picker" : "install", + selectedConnection: null, + pollingConnectionId: null, + authCheckId: null, + }; +} + +/** User picked an already-active existing connection. */ +export function onPickActive( + connection: ConnectionEntity, +): Partial { + return { + selectedConnection: connection, + phase: "done", + }; +} + +/** User picked an existing connection that isn't active yet — poll for it. */ +export function onPickInactive( + connection: ConnectionEntity, +): Partial { + return { + selectedConnection: connection, + pollingConnectionId: connection.id, + phase: "polling", + }; +} + +/** User chose "Install fresh" from the picker. */ +export function onInstallFresh(): Partial { + return { phase: "install" }; +} diff --git a/apps/mesh/src/web/components/connections-setup/slot-card.test.tsx b/apps/mesh/src/web/components/connections-setup/slot-card.test.tsx new file mode 100644 index 0000000000..24f735ae78 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-card.test.tsx @@ -0,0 +1,632 @@ +/// +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import type { SlotResolution } from "./use-slot-resolution"; +import type { ConnectionPollerResult } from "./use-connection-poller"; + +// ── Module mocks (hoisted by Bun before imports) ───────────────────────────── + +const mockUseSlotResolution = mock(() => makeResolution()); +const mockUseConnectionPoller = mock(() => makePoller()); +const mockIsConnectionAuthenticated = mock(() => + Promise.resolve({ supportsOAuth: false }), +); + +mock.module("./use-slot-resolution", () => ({ + useSlotResolution: mockUseSlotResolution, +})); + +mock.module("./use-connection-poller", () => ({ + useConnectionPoller: mockUseConnectionPoller, +})); + +mock.module("@decocms/mesh-sdk", () => ({ + isConnectionAuthenticated: mockIsConnectionAuthenticated, +})); + +// Stub child components — expose only what SlotCard passes to them +mock.module("./slot-install-form", () => ({ + SlotInstallForm: ({ onInstalled }: { onInstalled: (id: string) => void }) => + React.createElement( + "button", + { "data-testid": "install-btn", onClick: () => onInstalled("conn_new") }, + "Install", + ), +})); + +mock.module("./slot-done", () => ({ + SlotDone: ({ + label, + connection, + onReset, + }: { + label: string; + connection: ConnectionEntity; + onReset: () => void; + }) => + React.createElement( + "div", + { "data-testid": "slot-done" }, + React.createElement("span", { "data-testid": "done-label" }, label), + React.createElement( + "span", + { "data-testid": "done-connection" }, + connection.title, + ), + React.createElement( + "button", + { "data-testid": "change-btn", onClick: onReset }, + "Change", + ), + ), +})); + +mock.module("./slot-auth-oauth", () => ({ + SlotAuthOAuth: ({ + connectionId, + onAuthed, + }: { + connectionId: string; + onAuthed: () => void; + }) => + React.createElement( + "button", + { + "data-testid": "oauth-btn", + "data-connection-id": connectionId, + onClick: onAuthed, + }, + "Authorize", + ), +})); + +mock.module("./slot-auth-token", () => ({ + SlotAuthToken: ({ + connectionId, + onAuthed, + }: { + connectionId: string; + onAuthed: () => void; + }) => + React.createElement( + "button", + { + "data-testid": "token-btn", + "data-connection-id": connectionId, + onClick: onAuthed, + }, + "Submit Token", + ), +})); + +// ── Import component after mocks ───────────────────────────────────────────── + +// Bun hoists mock.module calls, so static import gets the mocked dependencies +import { SlotCard } from "./slot-card"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeConn( + overrides: Partial & { + metadata?: Record; + } = {}, +): ConnectionEntity { + return { + id: "conn_test", + title: "Test Connection", + status: "active", + connection_type: "HTTP", + connection_url: null, + connection_token: null, + connection_headers: null, + oauth_config: null, + configuration_state: null, + configuration_scopes: null, + description: null, + icon: null, + app_name: null, + app_id: null, + tools: null, + bindings: null, + organization_id: "org_1", + created_by: "user_1", + updated_by: "user_1", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + } as unknown as ConnectionEntity; +} + +function makeResolution( + overrides: Partial = {}, +): SlotResolution { + return { + initialPhase: "install", + registryItem: { + id: "item_1", + title: "Test MCP", + server: { name: "deco/test" }, + created_at: "", + updated_at: "", + } as SlotResolution["registryItem"], + matchingConnections: [], + satisfiedConnection: null, + isLoading: false, + registryError: null, + ...overrides, + }; +} + +function makePoller( + overrides: Partial = {}, +): ConnectionPollerResult { + return { + connection: null, + isActive: false, + isTimedOut: false, + isPolling: false, + ...overrides, + }; +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); +} + +const slot = { + label: "Test MCP", + registry: "deco-registry", + item_id: "deco/test", +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +beforeEach(() => { + mockUseSlotResolution.mockReset(); + mockUseConnectionPoller.mockReset(); + mockIsConnectionAuthenticated.mockReset(); + mockIsConnectionAuthenticated.mockReturnValue( + Promise.resolve({ supportsOAuth: false }), + ); +}); + +describe("SlotCard", () => { + describe("loading state", () => { + it("shows slot label and no form while registry item loads", () => { + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "loading", isLoading: true }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + + expect(screen.getByText("Test MCP")).toBeInTheDocument(); + expect(screen.queryByTestId("install-btn")).toBeNull(); + expect(screen.queryByTestId("slot-done")).toBeNull(); + }); + }); + + describe("registry error state", () => { + it("shows the error message when the registry item is not found", () => { + mockUseSlotResolution.mockReturnValue( + makeResolution({ registryError: "Registry item not found." }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + + expect(screen.getByText("Registry item not found.")).toBeInTheDocument(); + expect(screen.queryByTestId("install-btn")).toBeNull(); + }); + }); + + describe("install phase", () => { + it("renders the install form when no existing connections", () => { + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + + expect(screen.getByTestId("install-btn")).toBeInTheDocument(); + }); + + it("transitions to polling when the install form submits", () => { + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + + fireEvent.click(screen.getByTestId("install-btn")); + + expect(screen.getByText("Connecting...")).toBeInTheDocument(); + expect(screen.queryByTestId("install-btn")).toBeNull(); + }); + }); + + describe("polling phase", () => { + it("shows Connecting... spinner while polling", () => { + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller({ isPolling: true })); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + fireEvent.click(screen.getByTestId("install-btn")); + + expect(screen.getByText("Connecting...")).toBeInTheDocument(); + }); + + it("transitions to done and calls onComplete when the connection becomes active", () => { + const conn = makeConn({ + id: "conn_abc", + title: "OpenRouter", + status: "active", + }); + const onComplete = mock(() => {}); + + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + const { rerender } = render( + React.createElement(SlotCard, { slot, onComplete }), + { wrapper: createWrapper() }, + ); + fireEvent.click(screen.getByTestId("install-btn")); + + mockUseConnectionPoller.mockReturnValue( + makePoller({ isActive: true, connection: conn }), + ); + rerender(React.createElement(SlotCard, { slot, onComplete })); + + expect(screen.getByTestId("done-connection")).toHaveTextContent( + "OpenRouter", + ); + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith("conn_abc"); + }); + + it("only calls onComplete once across multiple re-renders with the same active connection", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + const onComplete = mock(() => {}); + + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + const { rerender } = render( + React.createElement(SlotCard, { slot, onComplete }), + { wrapper: createWrapper() }, + ); + // Click install to set pollingConnectionId, then poller goes active + fireEvent.click(screen.getByTestId("install-btn")); + + mockUseConnectionPoller.mockReturnValue( + makePoller({ isActive: true, connection: conn }), + ); + rerender(React.createElement(SlotCard, { slot, onComplete })); + rerender(React.createElement(SlotCard, { slot, onComplete })); + rerender(React.createElement(SlotCard, { slot, onComplete })); + + expect(onComplete).toHaveBeenCalledTimes(1); + }); + }); + + describe("auth-oauth phase", () => { + it("shows OAuth button when polling times out and endpoint supports OAuth", async () => { + const conn = makeConn({ id: "conn_auth", status: "inactive" }); + mockIsConnectionAuthenticated.mockResolvedValue({ supportsOAuth: true }); + + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + const { rerender } = render( + React.createElement(SlotCard, { slot, onComplete: () => {} }), + { wrapper: createWrapper() }, + ); + fireEvent.click(screen.getByTestId("install-btn")); + + mockUseConnectionPoller.mockReturnValue( + makePoller({ isTimedOut: true, connection: conn }), + ); + rerender(React.createElement(SlotCard, { slot, onComplete: () => {} })); + + await waitFor(() => + expect(screen.getByTestId("oauth-btn")).toBeInTheDocument(), + ); + }); + + it("shows OAuth button even when the poller never fetched a connection entity (null connection fix)", async () => { + mockIsConnectionAuthenticated.mockResolvedValue({ supportsOAuth: true }); + + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + const { rerender } = render( + React.createElement(SlotCard, { slot, onComplete: () => {} }), + { wrapper: createWrapper() }, + ); + fireEvent.click(screen.getByTestId("install-btn")); + + // connection is null — this was the "stuck state" bug + mockUseConnectionPoller.mockReturnValue( + makePoller({ isTimedOut: true, connection: null }), + ); + rerender(React.createElement(SlotCard, { slot, onComplete: () => {} })); + + await waitFor(() => + expect(screen.getByTestId("oauth-btn")).toBeInTheDocument(), + ); + }); + + it("returns to polling after OAuth completes", async () => { + const conn = makeConn({ id: "conn_auth" }); + mockIsConnectionAuthenticated.mockResolvedValue({ supportsOAuth: true }); + + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + const { rerender } = render( + React.createElement(SlotCard, { slot, onComplete: () => {} }), + { wrapper: createWrapper() }, + ); + fireEvent.click(screen.getByTestId("install-btn")); + + mockUseConnectionPoller.mockReturnValue( + makePoller({ isTimedOut: true, connection: conn }), + ); + rerender(React.createElement(SlotCard, { slot, onComplete: () => {} })); + + await waitFor(() => screen.getByTestId("oauth-btn")); + fireEvent.click(screen.getByTestId("oauth-btn")); + + expect(screen.getByText("Connecting...")).toBeInTheDocument(); + }); + }); + + describe("auth-token phase", () => { + it("shows token input when polling times out and endpoint does not support OAuth", async () => { + const conn = makeConn({ id: "conn_token" }); + mockIsConnectionAuthenticated.mockResolvedValue({ supportsOAuth: false }); + + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + const { rerender } = render( + React.createElement(SlotCard, { slot, onComplete: () => {} }), + { wrapper: createWrapper() }, + ); + fireEvent.click(screen.getByTestId("install-btn")); + + mockUseConnectionPoller.mockReturnValue( + makePoller({ isTimedOut: true, connection: conn }), + ); + rerender(React.createElement(SlotCard, { slot, onComplete: () => {} })); + + await waitFor(() => + expect(screen.getByTestId("token-btn")).toBeInTheDocument(), + ); + }); + + it("returns to polling after token auth completes", async () => { + const conn = makeConn({ id: "conn_token" }); + mockIsConnectionAuthenticated.mockResolvedValue({ supportsOAuth: false }); + + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "install" }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + const { rerender } = render( + React.createElement(SlotCard, { slot, onComplete: () => {} }), + { wrapper: createWrapper() }, + ); + fireEvent.click(screen.getByTestId("install-btn")); + + mockUseConnectionPoller.mockReturnValue( + makePoller({ isTimedOut: true, connection: conn }), + ); + rerender(React.createElement(SlotCard, { slot, onComplete: () => {} })); + + await waitFor(() => screen.getByTestId("token-btn")); + fireEvent.click(screen.getByTestId("token-btn")); + + expect(screen.getByText("Connecting...")).toBeInTheDocument(); + }); + }); + + describe("done phase", () => { + it("shows the done card when an existing connection is already active", () => { + const conn = makeConn({ id: "conn_abc", title: "OpenRouter" }); + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "done", satisfiedConnection: conn }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + + expect(screen.getByTestId("done-connection")).toHaveTextContent( + "OpenRouter", + ); + }); + + it("goes to picker when Change is clicked and existing connections are present", () => { + const conn = makeConn({ id: "conn_abc", title: "OpenRouter" }); + mockUseSlotResolution.mockReturnValue( + makeResolution({ + initialPhase: "done", + satisfiedConnection: conn, + matchingConnections: [conn], + }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + fireEvent.click(screen.getByTestId("change-btn")); + + expect(screen.getByText("Already installed:")).toBeInTheDocument(); + }); + + it("goes to install when Change is clicked and no existing connections remain", () => { + const conn = makeConn({ id: "conn_abc", title: "OpenRouter" }); + mockUseSlotResolution.mockReturnValue( + makeResolution({ + initialPhase: "done", + satisfiedConnection: conn, + matchingConnections: [], + }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + fireEvent.click(screen.getByTestId("change-btn")); + + expect(screen.getByTestId("install-btn")).toBeInTheDocument(); + }); + + it("calls onComplete with empty string when the user resets", () => { + const conn = makeConn({ id: "conn_abc" }); + const onComplete = mock(() => {}); + mockUseSlotResolution.mockReturnValue( + makeResolution({ + initialPhase: "done", + satisfiedConnection: conn, + matchingConnections: [], + }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete }), { + wrapper: createWrapper(), + }); + fireEvent.click(screen.getByTestId("change-btn")); + + expect(onComplete).toHaveBeenCalledWith(""); + }); + }); + + describe("picker phase", () => { + it("lists all matching connections", () => { + const conns = [ + makeConn({ id: "conn_1", title: "OpenRouter #1", status: "active" }), + makeConn({ id: "conn_2", title: "OpenRouter #2", status: "inactive" }), + ]; + mockUseSlotResolution.mockReturnValue( + makeResolution({ initialPhase: "picker", matchingConnections: conns }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + + expect(screen.getByText("OpenRouter #1")).toBeInTheDocument(); + expect(screen.getByText("OpenRouter #2")).toBeInTheDocument(); + }); + + it("transitions to done and calls onComplete when an active connection is selected", () => { + const conn = makeConn({ + id: "conn_active", + title: "OpenRouter", + status: "active", + }); + const onComplete = mock(() => {}); + mockUseSlotResolution.mockReturnValue( + makeResolution({ + initialPhase: "picker", + matchingConnections: [conn], + }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete }), { + wrapper: createWrapper(), + }); + fireEvent.click(screen.getByText("OpenRouter")); + + expect(screen.getByTestId("slot-done")).toBeTruthy(); + expect(onComplete).toHaveBeenCalledWith("conn_active"); + }); + + it("starts polling when an inactive connection is selected", () => { + const conn = makeConn({ + id: "conn_inactive", + title: "OpenRouter", + status: "inactive", + }); + mockUseSlotResolution.mockReturnValue( + makeResolution({ + initialPhase: "picker", + matchingConnections: [conn], + }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + fireEvent.click(screen.getByText("OpenRouter")); + + expect(screen.getByText("Connecting...")).toBeTruthy(); + }); + + it("transitions to install when Install fresh is clicked", () => { + const conn = makeConn({ + id: "conn_1", + title: "OpenRouter", + status: "active", + }); + mockUseSlotResolution.mockReturnValue( + makeResolution({ + initialPhase: "picker", + matchingConnections: [conn], + }), + ); + mockUseConnectionPoller.mockReturnValue(makePoller()); + + render(React.createElement(SlotCard, { slot, onComplete: () => {} }), { + wrapper: createWrapper(), + }); + fireEvent.click(screen.getByText("Install fresh")); + + expect(screen.getByTestId("install-btn")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/mesh/src/web/components/connections-setup/slot-card.tsx b/apps/mesh/src/web/components/connections-setup/slot-card.tsx index 7fe43e26a8..47928c1e32 100644 --- a/apps/mesh/src/web/components/connections-setup/slot-card.tsx +++ b/apps/mesh/src/web/components/connections-setup/slot-card.tsx @@ -10,6 +10,18 @@ import { KEYS } from "@/web/lib/query-keys"; import { useSlotResolution, type ConnectionSlot } from "./use-slot-resolution"; import { useConnectionPoller } from "./use-connection-poller"; import { type SlotPhase } from "./slot-resolution"; +import { + onAuthed, + onInstallFresh, + onInstalled, + onPickActive, + onPickInactive, + onPollerActive, + onPollerTimeout, + onAuthStatus, + onReset, + type SlotCardState, +} from "./slot-card-transitions"; import { SlotDone } from "./slot-done"; import { SlotInstallForm } from "./slot-install-form"; import { SlotAuthOAuth } from "./slot-auth-oauth"; @@ -20,6 +32,24 @@ interface SlotCardProps { onComplete: (connectionId: string) => void; } +/** Apply a partial state transition to the individual React state setters. */ +function applyTransition( + t: Partial, + setters: { + setPhase: (v: SlotPhase | null) => void; + setPollingConnectionId: (v: string | null) => void; + setSelectedConnection: (v: ConnectionEntity | null) => void; + setAuthCheckId: (v: string | null) => void; + }, +) { + if ("phase" in t) setters.setPhase(t.phase ?? null); + if ("pollingConnectionId" in t) + setters.setPollingConnectionId(t.pollingConnectionId ?? null); + if ("selectedConnection" in t) + setters.setSelectedConnection(t.selectedConnection ?? null); + if ("authCheckId" in t) setters.setAuthCheckId(t.authCheckId ?? null); +} + export function SlotCard({ slot, onComplete }: SlotCardProps) { const resolution = useSlotResolution(slot); const [phase, setPhase] = useState(null); @@ -33,6 +63,13 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { // Prevents onComplete from firing more than once per unique connection const completedIdRef = useRef(null); + const setters = { + setPhase, + setPollingConnectionId, + setSelectedConnection, + setAuthCheckId, + }; + const poller = useConnectionPoller(pollingConnectionId); const authUrl = authCheckId @@ -53,9 +90,7 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { if (pollingConnectionId && poller.isActive && poller.connection) { if (completedIdRef.current !== poller.connection.id) { completedIdRef.current = poller.connection.id; - setPollingConnectionId(null); - setSelectedConnection(poller.connection); - setPhase("done"); + applyTransition(onPollerActive(poller.connection), setters); onComplete(poller.connection.id); } } @@ -65,9 +100,10 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { pollingConnectionId && (poller.isTimedOut || poller.connection?.status === "error") ) { - if (poller.connection) setSelectedConnection(poller.connection); - setAuthCheckId(pollingConnectionId); - setPollingConnectionId(null); + applyTransition( + onPollerTimeout(pollingConnectionId, poller.connection ?? null), + setters, + ); } // React to auth check result — set the appropriate auth phase @@ -77,30 +113,23 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { phase !== "auth-oauth" && phase !== "auth-token" ) { - setPhase(authStatus.supportsOAuth ? "auth-oauth" : "auth-token"); + applyTransition(onAuthStatus(authStatus.supportsOAuth), setters); } const handleInstalled = (connectionId: string) => { - setPollingConnectionId(connectionId); - setPhase("polling"); + applyTransition(onInstalled(connectionId), setters); }; const handleAuthed = () => { - const id = - pollingConnectionId ?? selectedConnection?.id ?? authCheckId ?? null; - if (id) { - setAuthCheckId(null); - setPollingConnectionId(id); - setPhase("polling"); - } + applyTransition( + onAuthed({ pollingConnectionId, selectedConnection, authCheckId }), + setters, + ); }; const handleReset = () => { const hasExisting = resolution.matchingConnections.length > 0; - setPhase(hasExisting ? "picker" : "install"); - setSelectedConnection(null); - setPollingConnectionId(null); - setAuthCheckId(null); + applyTransition(onReset(hasExisting), setters); completedIdRef.current = null; onComplete(""); }; @@ -158,13 +187,10 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { type="button" onClick={() => { if (conn.status === "active") { - setSelectedConnection(conn); - setPhase("done"); + applyTransition(onPickActive(conn), setters); onComplete(conn.id); } else { - setSelectedConnection(conn); - setPollingConnectionId(conn.id); - setPhase("polling"); + applyTransition(onPickInactive(conn), setters); } }} className="w-full flex items-center justify-between rounded border px-3 py-2 text-sm hover:bg-accent transition-colors" @@ -180,7 +206,7 @@ export function SlotCard({ slot, onComplete }: SlotCardProps) { variant="outline" size="sm" className="w-full" - onClick={() => setPhase("install")} + onClick={() => applyTransition(onInstallFresh(), setters)} > Install fresh diff --git a/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts b/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts index fa2c0eae20..e83393ac2b 100644 --- a/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts +++ b/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts @@ -52,7 +52,7 @@ export function useSlotResolution(slot: ConnectionSlot): SlotResolution { registryConn.id, org.id, listTool, - { where: { id: slot.item_id } }, + { where: { appName: slot.item_id } }, ); const items = extractItemsFromResponse(result); return items[0] ?? null; diff --git a/apps/mesh/src/web/routes/orgs/connections.tsx b/apps/mesh/src/web/routes/orgs/connections.tsx index 67517f5a63..fe27e68395 100644 --- a/apps/mesh/src/web/routes/orgs/connections.tsx +++ b/apps/mesh/src/web/routes/orgs/connections.tsx @@ -1,4 +1,5 @@ import { generatePrefixedId } from "@/shared/utils/generate-id"; +import { ConnectionsSetup } from "@/web/components/connections-setup"; import { CollectionDisplayButton } from "@/web/components/collections/collection-display-button.tsx"; import { CollectionSearch } from "@/web/components/collections/collection-search.tsx"; import { CollectionTableWrapper } from "@/web/components/collections/collection-table-wrapper.tsx"; @@ -90,7 +91,7 @@ import { Terminal, Trash01, } from "@untitledui/icons"; -import { Suspense, useEffect, useReducer } from "react"; +import { Suspense, useEffect, useReducer, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { formatTimeAgo } from "@/web/lib/format-time"; @@ -419,6 +420,7 @@ function OrgMcpsContent() { const connections = useConnections(listState); const [dialogState, dispatch] = useReducer(dialogReducer, { mode: "idle" }); + const [isSetupOpen, setIsSetupOpen] = useState(false); // Optional registry lookup: use first available registry connection as a name/description source const registryConnection = useRegistryConnections(connections)[0]; @@ -1000,6 +1002,14 @@ function OrgMcpsContent() { const ctaButton = (
+