From 481005fddf5b9271d8395a8bbcdd7f41f4b1a72b Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 1 Jul 2026 00:02:39 -0700 Subject: [PATCH] [eric] onboarding: fix Connect doing nothing (extract shared useConnectIntegration: create->oauth->named popup->poll status; reused downward) --- .../Onboarding/flow/steps/ConnectApps.tsx | 37 +++++-------- .../integrations/useConnectIntegration.ts | 54 +++++++++++++++++++ 2 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 frontend/src/shared/integrations/useConnectIntegration.ts diff --git a/frontend/src/app/components/Onboarding/flow/steps/ConnectApps.tsx b/frontend/src/app/components/Onboarding/flow/steps/ConnectApps.tsx index 5aabb970..d2c06343 100644 --- a/frontend/src/app/components/Onboarding/flow/steps/ConnectApps.tsx +++ b/frontend/src/app/components/Onboarding/flow/steps/ConnectApps.tsx @@ -1,10 +1,11 @@ // D4: optional connect. Reuses the REAL shared connector catalog (icons + metadata), the REAL // connection state from toolsSlice, and the REAL create+OAuth thunks. All connectable MCPs, scrollable. -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; import { INTEGRATIONS, Integration } from '@/shared/integrations/catalog'; -import { fetchTools, createTool, startOAuth, startDeviceCodeLogin, ToolDefinition } from '@/shared/state/toolsSlice'; +import { useConnectIntegration } from '@/shared/integrations/useConnectIntegration'; +import { fetchTools, ToolDefinition } from '@/shared/state/toolsSlice'; import { useOnboardingSkin } from '../onboardingSkin'; import { Heading, Sub, PrimaryButton, GhostLink } from '../OnboardingAtoms'; @@ -14,30 +15,17 @@ function toolFor(integration: Integration, items: Record const Row: React.FC<{ integration: Integration }> = ({ integration }) => { const S = useOnboardingSkin(); - const dispatch = useAppDispatch(); + const connectIntegration = useConnectIntegration(); const items = useAppSelector((s) => s.tools.items); const tool = toolFor(integration, items); const connected = tool?.auth_status === 'connected'; + const [connecting, setConnecting] = useState(false); const connect = async () => { - try { - // The tool has to exist before OAuth (fresh installs have none) -> create from the catalog first. - let t = tool; - if (!t) { - t = await dispatch(createTool({ - name: integration.name, - description: integration.description, - mcp_config: integration.mcp_config, - auth_type: integration.authType ?? 'oauth2', - })).unwrap(); - } - if (integration.authType === 'device_code') { - await dispatch(startDeviceCodeLogin(t.id)); - } else { - const res = await dispatch(startOAuth(t.id)).unwrap(); - if (res.auth_url) window.open(res.auth_url, '_blank'); - } - } catch { /* connect failed; leave as-is */ } + if (connecting) return; + setConnecting(true); + await connectIntegration(integration, tool); + setConnecting(false); }; return ( @@ -50,7 +38,7 @@ const Row: React.FC<{ integration: Integration }> = ({ integration }) => { = ({ integration }) => { border: `1px solid ${connected ? S.border : S.borderStrong}`, borderRadius: 999, padding: '6px 15px', - cursor: connected ? 'default' : 'pointer', + cursor: connected || connecting ? 'default' : 'pointer', whiteSpace: 'nowrap', + opacity: connecting ? 0.6 : 1, }} > - {connected ? 'Connected' : 'Connect'} + {connected ? 'Connected' : connecting ? 'Connecting…' : 'Connect'} ); diff --git a/frontend/src/shared/integrations/useConnectIntegration.ts b/frontend/src/shared/integrations/useConnectIntegration.ts new file mode 100644 index 00000000..3f184fcb --- /dev/null +++ b/frontend/src/shared/integrations/useConnectIntegration.ts @@ -0,0 +1,54 @@ +// Shared connect primitive: create the tool (if needed) -> start OAuth / device-code -> open the +// popup -> poll status until connected. Lives in shared/ so both the Tools page and Onboarding use +// the SAME proven flow (downward abstraction), instead of each re-implementing it. + +import { useCallback } from 'react'; +import { useAppDispatch } from '@/shared/hooks'; +import { createTool, startOAuth, startDeviceCodeLogin, fetchToolStatus, ToolDefinition } from '@/shared/state/toolsSlice'; +import { Integration } from './catalog'; + +const POLL_MS = 2000; +const POLL_MAX = 60; // ~2 min + +export interface ConnectResult { + status: 'connected' | 'cancelled' | 'error'; +} + +export function useConnectIntegration(): (integration: Integration, existing?: ToolDefinition) => Promise { + const dispatch = useAppDispatch(); + + return useCallback(async (integration: Integration, existing?: ToolDefinition): Promise => { + try { + // The tool must exist before OAuth (fresh installs have none) -> create from the catalog. + let tool = existing; + if (!tool) { + tool = await dispatch(createTool({ + name: integration.name, + description: integration.description, + mcp_config: integration.mcp_config, + auth_type: integration.authType ?? 'oauth2', + })).unwrap(); + } + + if (integration.authType === 'device_code') { + await dispatch(startDeviceCodeLogin(tool.id)); + return { status: 'connected' }; + } + + const { auth_url } = await dispatch(startOAuth(tool.id)).unwrap(); + if (!auth_url) return { status: 'error' }; + // Named popup + features = the pattern that actually opens in Electron (a bare _blank is swallowed). + window.open(auth_url, 'oauth', 'width=500,height=700,left=200,top=100'); + + // Poll until the OAuth round-trip flips auth_status to connected. + for (let i = 0; i < POLL_MAX; i++) { + await new Promise((r) => setTimeout(r, POLL_MS)); + const t = await dispatch(fetchToolStatus(tool.id)).unwrap(); + if (t.auth_status === 'connected') return { status: 'connected' }; + } + return { status: 'cancelled' }; + } catch { + return { status: 'error' }; + } + }, [dispatch]); +}