diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 70f447cd..55ef8079 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -1,7 +1,8 @@ /** * Add Account Dialog Component * Uses /start-url to get OAuth URL + polls for completion via management API. - * Does NOT call /start (which spawns a CLIProxy binary and kills running instances). + * For Device Code flows (ghcp, qwen): Uses /start endpoint which spawns CLIProxy + * binary and emits WebSocket events. DeviceCodeDialog handles user code display. * Shows auth URL + callback paste field. Polling auto-closes on success. * For Kiro: Also shows "Import from IDE" option. */ @@ -21,6 +22,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react import { useKiroImport } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; +import { isDeviceCodeProvider } from '@/lib/provider-config'; import { toast } from 'sonner'; interface AddAccountDialogProps { @@ -47,6 +49,7 @@ export function AddAccountDialog({ const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; + const isDeviceCode = isDeviceCodeProvider(provider); const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; const resetAndClose = () => { @@ -144,7 +147,9 @@ export function AddAccountDialog({ {isKiro ? 'Authenticate via browser or import an existing token from Kiro IDE.' - : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} + : isDeviceCode + ? 'Click Authenticate. A verification code will appear for you to enter on the provider website.' + : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} @@ -180,17 +185,19 @@ export function AddAccountDialog({ Waiting for authentication...

- Complete the authentication in your browser. This dialog closes automatically. + {authFlow.isDeviceCodeFlow + ? 'A verification code dialog will appear shortly. Enter the code on the provider website.' + : 'Complete the authentication in your browser. This dialog closes automatically.'}

- {/* Error from /start-url - fallback URL not available */} + {/* Error display */} {authFlow.error && !authFlow.authUrl && (

{authFlow.error}

)} - {/* Auth URL section - appears once /start-url returns */} - {authFlow.authUrl && ( + {/* Auth URL section - only for Authorization Code flows, NOT Device Code */} + {authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index d7a7663a..433af6e2 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -7,7 +7,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { api } from '@/lib/api-client'; -import { isValidProvider } from '@/lib/provider-config'; +import { isValidProvider, isDeviceCodeProvider } from '@/lib/provider-config'; interface AuthFlowState { provider: string | null; @@ -19,6 +19,8 @@ interface AuthFlowState { oauthState: string | null; /** Whether callback is being submitted */ isSubmittingCallback: boolean; + /** Whether this is a device code flow (ghcp, qwen) - dialog handled separately via WebSocket */ + isDeviceCodeFlow: boolean; } interface StartAuthOptions { @@ -30,15 +32,19 @@ const POLL_INTERVAL = 3000; /** Maximum polling duration (5 minutes) */ const MAX_POLL_DURATION = 5 * 60 * 1000; +/** Initial state for auth flow - extracted for DRY */ +const INITIAL_STATE: AuthFlowState = { + provider: null, + isAuthenticating: false, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + isDeviceCodeFlow: false, +}; + export function useCliproxyAuthFlow() { - const [state, setState] = useState({ - provider: null, - isAuthenticating: false, - error: null, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, - }); + const [state, setState] = useState(INITIAL_STATE); const abortControllerRef = useRef(null); const pollIntervalRef = useRef | null>(null); @@ -86,14 +92,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${provider} authentication successful`); - setState({ - provider: null, - isAuthenticating: false, - error: null, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, - }); + setState(INITIAL_STATE); } else if (data.status === 'error') { stopPolling(); const errorMsg = data.error || 'Authentication failed'; @@ -116,12 +115,8 @@ export function useCliproxyAuthFlow() { async (provider: string, options?: StartAuthOptions) => { if (!isValidProvider(provider)) { setState({ - provider: null, - isAuthenticating: false, + ...INITIAL_STATE, error: `Unknown provider: ${provider}`, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, }); return; } @@ -134,6 +129,8 @@ export function useCliproxyAuthFlow() { const controller = new AbortController(); abortControllerRef.current = controller; + const deviceCodeFlow = isDeviceCodeProvider(provider); + setState({ provider, isAuthenticating: true, @@ -141,52 +138,90 @@ export function useCliproxyAuthFlow() { authUrl: null, oauthState: null, isSubmittingCallback: false, + isDeviceCodeFlow: deviceCodeFlow, }); try { - // Call start-url to get auth URL immediately (non-blocking) - const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nickname: options?.nickname }), - signal: controller.signal, - }); + if (deviceCodeFlow) { + // Device Code Flow: Call /start endpoint which spawns CLIProxyAPI binary. + // This emits WebSocket events with userCode that DeviceCodeDialog will display. + // The /start endpoint blocks until completion, so we don't await it here. + fetch(`/api/cliproxy/auth/${provider}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nickname: options?.nickname }), + signal: controller.signal, + }) + .then(async (response) => { + const data = await response.json(); + if (response.ok && data.success) { + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast + // via deviceCodeCompleted WebSocket event to avoid duplicate toasts + setState(INITIAL_STATE); + } else { + const errorMsg = data.error || 'Authentication failed'; + toast.error(errorMsg); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: errorMsg, + })); + } + }) + .catch((error) => { + if (error instanceof Error && error.name === 'AbortError') { + // Cancelled - state already reset by cancelAuth + return; + } + const message = error instanceof Error ? error.message : 'Authentication failed'; + toast.error(message); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: message, + })); + }); + // Don't await - let the request run in background while DeviceCodeDialog handles UI + } else { + // Authorization Code Flow: Call /start-url to get auth URL immediately (non-blocking) + const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ nickname: options?.nickname }), + signal: controller.signal, + }); - const data = await response.json(); + const data = await response.json(); - if (!response.ok || !data.success) { - throw new Error(data.error || 'Failed to start OAuth'); - } + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to start OAuth'); + } - // Update state with auth URL - setState((prev) => ({ - ...prev, - authUrl: data.authUrl, - oauthState: data.state, - })); + // Update state with auth URL + setState((prev) => ({ + ...prev, + authUrl: data.authUrl, + oauthState: data.state, + })); - // Auto-open auth URL in new browser tab (fallback URL still shown in dialog) - if (data.authUrl) { - window.open(data.authUrl, '_blank'); - } + // Auto-open auth URL in new browser tab (fallback URL still shown in dialog) + if (data.authUrl) { + window.open(data.authUrl, '_blank'); + } - // Start polling for completion - if (data.state) { - pollStartRef.current = Date.now(); - pollIntervalRef.current = setInterval(() => { - pollStatus(provider, data.state); - }, POLL_INTERVAL); + // Start polling for completion + if (data.state) { + pollStartRef.current = Date.now(); + pollIntervalRef.current = setInterval(() => { + pollStatus(provider, data.state); + }, POLL_INTERVAL); + } } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - setState({ - provider: null, - isAuthenticating: false, - error: null, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, - }); + setState(INITIAL_STATE); return; } const message = error instanceof Error ? error.message : 'Authentication failed'; @@ -198,21 +233,14 @@ export function useCliproxyAuthFlow() { })); } }, - [pollStatus, stopPolling] + [pollStatus, stopPolling, queryClient] ); const cancelAuth = useCallback(() => { const currentProvider = state.provider; abortControllerRef.current?.abort(); stopPolling(); - setState({ - provider: null, - isAuthenticating: false, - error: null, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, - }); + setState(INITIAL_STATE); // Also cancel on backend if (currentProvider) { api.cliproxy.auth.cancel(currentProvider).catch(() => { @@ -241,14 +269,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${state.provider} authentication successful`); - setState({ - provider: null, - isAuthenticating: false, - error: null, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, - }); + setState(INITIAL_STATE); } else { throw new Error(data.error || 'Callback submission failed'); } diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index f0b8743e..a7ca02a1 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -71,3 +71,14 @@ const PROVIDER_NAMES: Record = { export function getProviderDisplayName(provider: string): string { return PROVIDER_NAMES[provider.toLowerCase()] || provider; } + +/** + * Providers that use Device Code OAuth flow instead of Authorization Code flow. + * Device Code flow requires displaying a user code for manual entry at provider's website. + */ +export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'qwen']; + +/** Check if provider uses Device Code flow */ +export function isDeviceCodeProvider(provider: string): boolean { + return DEVICE_CODE_PROVIDERS.includes(provider as CLIProxyProvider); +}