diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 5e4664c68d..df5491a867 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -113,8 +113,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/web/components/connections-setup/connections-setup.tsx b/apps/mesh/src/web/components/connections-setup/connections-setup.tsx new file mode 100644 index 0000000000..ea9166eae4 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/connections-setup.tsx @@ -0,0 +1,50 @@ +import { useState, useRef } 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 wasAllDoneRef = useRef(false); + + const handleSlotComplete = (slotId: string, connectionId: string) => { + 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]) => ( + + 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-auth-oauth.tsx b/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx new file mode 100644 index 0000000000..303bab4844 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-auth-oauth.tsx @@ -0,0 +1,99 @@ +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) { + 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 { + await actions.update.mutateAsync({ + id: connectionId, + data: { connection_token: token }, + }); + } + + await queryClient.invalidateQueries({ + queryKey: KEYS.isMCPAuthenticated(mcpProxyUrl.href, null), + }); + + toast.success("Authorization successful"); + onAuthed(); + } finally { + setIsPending(false); + } + }; + + return ( +
+

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

+ +
+ ); +} 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 + + + + + + )} + /> + + + + ); +} 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..abb28e2bfc --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-card-transitions.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it } from "bun:test"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import type { McpAuthStatus } from "@decocms/mesh-sdk"; +import { + onAuthed, + onInstallFresh, + onInstalled, + onPickActive, + onPickInactive, + onPollerActive, + onPollerTimeout, + resolveAuthPhase, + 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("does NOT immediately go to done — queues an auth check instead", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + // phase must not be set here; the auth check will decide done vs auth-oauth + expect(onPollerActive(conn).phase).toBeUndefined(); + }); + + it("sets authCheckId so the auth query fires", () => { + const conn = makeConn({ id: "conn_abc", status: "active" }); + expect(onPollerActive(conn).authCheckId).toBe("conn_abc"); + }); + + 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(); + }); +}); + +// Helper to build a McpAuthStatus quickly +function makeAuthStatus(overrides: Partial = {}): McpAuthStatus { + return { + isAuthenticated: true, + supportsOAuth: false, + hasOAuthToken: false, + ...overrides, + }; +} + +describe("resolveAuthPhase", () => { + describe("connections with oauth_config (e.g. Gmail)", () => { + // Cast because OAuthConfig shape is complex — we just need it to be non-null + const oauthConn = { + ...makeConn(), + oauth_config: {}, + } as unknown as ConnectionEntity; + + it("returns auth-oauth when connection has oauth_config but no token yet — even if server returned 200", () => { + // Gmail initialize returns 200 without a token, so supportsOAuth is false, + // but we detect the need via oauth_config + !hasOAuthToken. + const status = makeAuthStatus({ + isAuthenticated: true, + supportsOAuth: false, + hasOAuthToken: false, + }); + expect(resolveAuthPhase(status, oauthConn, "active")).toBe("auth-oauth"); + }); + + it("returns done once OAuth token has been obtained", () => { + const status = makeAuthStatus({ + isAuthenticated: true, + supportsOAuth: false, + hasOAuthToken: true, + }); + expect(resolveAuthPhase(status, oauthConn, "active")).toBe("done"); + }); + }); + + describe("connections without oauth_config (e.g. simple HTTP MCPs)", () => { + const simpleConn = makeConn(); + + it("returns done when active and no auth needed", () => { + const status = makeAuthStatus({ + isAuthenticated: true, + supportsOAuth: false, + hasOAuthToken: false, + }); + expect(resolveAuthPhase(status, simpleConn, "active")).toBe("done"); + }); + + it("returns auth-token when timed out with no OAuth cues", () => { + const status = makeAuthStatus({ + isAuthenticated: false, + supportsOAuth: false, + hasOAuthToken: false, + }); + expect(resolveAuthPhase(status, simpleConn, "timeout")).toBe( + "auth-token", + ); + }); + }); + + describe("connections that return 401 + WWW-Authenticate (explicit OAuth challenge)", () => { + it("returns auth-oauth regardless of oauth_config when server returns OAuth challenge", () => { + const status = makeAuthStatus({ + isAuthenticated: false, + supportsOAuth: true, + hasOAuthToken: false, + }); + expect( + resolveAuthPhase(status, makeConn({ oauth_config: null }), "active"), + ).toBe("auth-oauth"); + expect( + resolveAuthPhase(status, makeConn({ oauth_config: null }), "timeout"), + ).toBe("auth-oauth"); + }); + }); + + describe("null selectedConnection (entity not fetched before timeout)", () => { + it("returns auth-token when timed out with no info", () => { + const status = makeAuthStatus({ + isAuthenticated: false, + supportsOAuth: false, + }); + expect(resolveAuthPhase(status, null, "timeout")).toBe("auth-token"); + }); + + it("returns auth-oauth when server signals OAuth on timeout", () => { + const status = makeAuthStatus({ + isAuthenticated: false, + supportsOAuth: true, + }); + expect(resolveAuthPhase(status, null, "timeout")).toBe("auth-oauth"); + }); + }); +}); + +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..980987eb2e --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-card-transitions.ts @@ -0,0 +1,136 @@ +import type { ConnectionEntity, McpAuthStatus } 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 at the transport level. + * We don't go to "done" yet — we always check auth first, because some + * connections (e.g. Gmail) respond 200 before OAuth is configured. + * onAuthStatus("active") then decides: no-OAuth → done, OAuth → auth-oauth. + */ +export function onPollerActive( + connection: ConnectionEntity, +): Partial { + return { + pollingConnectionId: null, + selectedConnection: connection, + authCheckId: connection.id, + }; +} + +/** + * 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, + }; +} + +/** + * Determine which auth phase to enter after the auth check completes. + * + * source "active" — poller confirmed the connection responds to initialize. + * OAuth needed? → "auth-oauth" (connection has oauth_config but no token yet, + * OR server returned 401 + WWW-Authenticate) + * No auth needed → "done" (working without auth, or OAuth already done) + * + * source "timeout" — connection never became active before the poll timeout. + * OAuth needed? → "auth-oauth" + * No OAuth cue → "auth-token" (not working; probably needs an API token) + * + * NOTE: supportsOAuth is only reliable when the server returns 401 + WWW-Authenticate. + * Many services (e.g. Gmail) accept initialize without auth, so we also check + * connection.oauth_config + hasOAuthToken to catch the "active but needs OAuth" case. + */ +export function resolveAuthPhase( + authStatus: McpAuthStatus, + selectedConnection: ConnectionEntity | null, + source: "active" | "timeout", +): "auth-oauth" | "auth-token" | "done" { + const needsOAuth = + // Connection was created with OAuth config and the token hasn't been obtained yet + (!!selectedConnection?.oauth_config && !authStatus.hasOAuthToken) || + // Server returned 401 + WWW-Authenticate (the reliable OAuth detection path) + (!authStatus.isAuthenticated && authStatus.supportsOAuth); + + if (needsOAuth) return "auth-oauth"; + if (source === "active" && authStatus.isAuthenticated) return "done"; + return "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.tsx b/apps/mesh/src/web/components/connections-setup/slot-card.tsx new file mode 100644 index 0000000000..75b3413b4a --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-card.tsx @@ -0,0 +1,263 @@ +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"; +import { + resolveAuthPhase, + onAuthed, + onInstallFresh, + onInstalled, + onPickActive, + onPickInactive, + onPollerActive, + onPollerTimeout, + onReset, + type SlotCardState, +} from "./slot-card-transitions"; +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; +} + +/** 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); + const [pollingConnectionId, setPollingConnectionId] = useState( + null, + ); + const [selectedConnection, setSelectedConnection] = + useState(null); + // ID of the connection whose auth status is being checked + const [authCheckId, setAuthCheckId] = useState(null); + + // Tracks whether the auth check was triggered by an active poller ("active") + // or a timeout/error ("timeout"). Determines the done-vs-auth-token outcome. + const authCheckSourceRef = useRef<"active" | "timeout">("timeout"); + // 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 + ? 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), + }); + + // Derive effective phase: explicit override takes priority, else from resolution + const effectivePhase: SlotPhase = phase ?? resolution.initialPhase; + + // React to poller becoming active — always check auth before marking done. + // Some connections (e.g. Gmail) respond 200 at the transport level even + // without OAuth, so we can't trust "active" to mean "authenticated". + if (pollingConnectionId && poller.isActive && poller.connection) { + authCheckSourceRef.current = "active"; + applyTransition(onPollerActive(poller.connection), setters); + } + + // React to poller timeout/error — check auth to determine token vs OAuth + if ( + pollingConnectionId && + (poller.isTimedOut || poller.connection?.status === "error") + ) { + authCheckSourceRef.current = "timeout"; + applyTransition( + onPollerTimeout(pollingConnectionId, poller.connection ?? null), + setters, + ); + } + + // React to auth check result + if ( + authCheckId && + authStatus && + phase !== "auth-oauth" && + phase !== "auth-token" && + phase !== "done" + ) { + const source = authCheckSourceRef.current; + const connId = selectedConnection?.id ?? authCheckId; + const nextPhase = resolveAuthPhase(authStatus, selectedConnection, source); + + if (nextPhase === "done") { + // Only call onComplete once per connection + if (completedIdRef.current !== connId) { + completedIdRef.current = connId; + setPhase("done"); + onComplete(connId); + } + } else { + setPhase(nextPhase); + } + } + + const handleInstalled = (connectionId: string) => { + applyTransition(onInstalled(connectionId), setters); + }; + + const handleAuthed = () => { + applyTransition( + onAuthed({ pollingConnectionId, selectedConnection, authCheckId }), + setters, + ); + }; + + const handleReset = () => { + const hasExisting = resolution.matchingConnections.length > 0; + applyTransition(onReset(hasExisting), setters); + 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 ( +
+ +

{slot.label}

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

{slot.label}

+

+ {resolution.registryError} +

+
+
+ ); + } + + return ( +
+ {effectivePhase !== "done" && ( +

{slot.label}

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

Already installed:

+
+ {resolution.matchingConnections.map((conn) => ( + + ))} +
+ +
+ )} + + {effectivePhase === "install" && resolution.registryItem && ( + + )} + + {effectivePhase === "polling" && ( +
+ + Connecting... +
+ )} + + {effectivePhase === "auth-oauth" && authConnectionId && ( + + )} + + {effectivePhase === "auth-token" && authConnectionId && ( + + )} +
+ ); +} 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..333e81f9d7 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-done.tsx @@ -0,0 +1,31 @@ +import { CheckCircle, ChevronDown } from "@untitledui/icons"; +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} +

+
+ +
+ ); +} 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..deac7ab8a8 --- /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, + }; + const created = await actions.create.mutateAsync(payload); + onInstalled(created.id); + }; + + return ( +
+ + ( + + Connection name + + + + + + )} + /> + + + + ); +} 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..33851cff3c --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/slot-resolution.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { + findMatchingConnections, + 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' when at least one active match exists among multiple", () => { + 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 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"; +} 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..cc06d1f6c6 --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/use-connection-poller.ts @@ -0,0 +1,78 @@ +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); + const prevConnectionIdRef = useRef(null); + + if (connectionId !== prevConnectionIdRef.current) { + prevConnectionIdRef.current = connectionId; + startTimeRef.current = connectionId ? Date.now() : 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?: { item: ConnectionEntity | null } }; + return result.structuredContent?.item ?? null; + } 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, + }; +} 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..e83393ac2b --- /dev/null +++ b/apps/mesh/src/web/components/connections-setup/use-slot-resolution.ts @@ -0,0 +1,98 @@ +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: { appName: slot.item_id } }, + ); + const items = extractItemsFromResponse(result); + return items[0] ?? null; + }, + enabled: Boolean(registryConn && org), + staleTime: 60 * 60 * 1000, + }); + + if (isLoadingItem) { + return { + initialPhase: "loading", + registryItem: null, + matchingConnections: [], + satisfiedConnection: null, + isLoading: true, + registryError: 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(allConnections, slot.item_id); + + return { + initialPhase, + registryItem: registryItem ?? null, + matchingConnections, + satisfiedConnection, + isLoading: false, + registryError, + }; +} 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, 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 = (
+ +
+ ); +} +``` + +**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.