From 13f6c3f14bd0d1c920e339b2486dcd6e37ce50f4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 5 Feb 2026 15:27:30 -0500 Subject: [PATCH 1/2] fix(ui): display device code for GitHub Copilot OAuth in Dashboard For Device Code OAuth providers (ghcp, qwen), switch from /start-url to /start endpoint which spawns CLIProxyAPI binary and emits WebSocket events with userCode. DeviceCodeDialog then displays the code properly. Closes #460 --- .../components/account/add-account-dialog.tsx | 19 ++- ui/src/hooks/use-cliproxy-auth-flow.ts | 120 +++++++++++++----- ui/src/lib/provider-config.ts | 11 ++ 3 files changed, 115 insertions(+), 35 deletions(-) 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..d17db21c 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 { @@ -38,6 +40,7 @@ export function useCliproxyAuthFlow() { authUrl: null, oauthState: null, isSubmittingCallback: false, + isDeviceCodeFlow: false, }); const abortControllerRef = useRef(null); @@ -93,6 +96,7 @@ export function useCliproxyAuthFlow() { authUrl: null, oauthState: null, isSubmittingCallback: false, + isDeviceCodeFlow: false, }); } else if (data.status === 'error') { stopPolling(); @@ -122,6 +126,7 @@ export function useCliproxyAuthFlow() { authUrl: null, oauthState: null, isSubmittingCallback: false, + isDeviceCodeFlow: false, }); return; } @@ -134,6 +139,8 @@ export function useCliproxyAuthFlow() { const controller = new AbortController(); abortControllerRef.current = controller; + const deviceCodeFlow = isDeviceCodeProvider(provider); + setState({ provider, isAuthenticating: true, @@ -141,41 +148,93 @@ 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'] }); + toast.success(`${provider} authentication successful`); + setState({ + provider: null, + isAuthenticating: false, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + isDeviceCodeFlow: false, + }); + } 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') { @@ -186,6 +245,7 @@ export function useCliproxyAuthFlow() { authUrl: null, oauthState: null, isSubmittingCallback: false, + isDeviceCodeFlow: false, }); return; } @@ -198,7 +258,7 @@ export function useCliproxyAuthFlow() { })); } }, - [pollStatus, stopPolling] + [pollStatus, stopPolling, queryClient] ); const cancelAuth = useCallback(() => { @@ -212,6 +272,7 @@ export function useCliproxyAuthFlow() { authUrl: null, oauthState: null, isSubmittingCallback: false, + isDeviceCodeFlow: false, }); // Also cancel on backend if (currentProvider) { @@ -248,6 +309,7 @@ export function useCliproxyAuthFlow() { authUrl: null, oauthState: null, isSubmittingCallback: false, + isDeviceCodeFlow: false, }); } 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); +} From a08d0cfece85ebb8c5960ad66ee48131af6ded05 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 5 Feb 2026 15:37:18 -0500 Subject: [PATCH 2/2] refactor(ui): address PR review feedback for device code auth - Extract INITIAL_STATE constant to reduce code repetition (DRY) - Remove duplicate success toast for device code flow (useDeviceCode already shows toast via deviceCodeCompleted WebSocket event) - Replace 7 inline state reset objects with INITIAL_STATE reference --- ui/src/hooks/use-cliproxy-auth-flow.ts | 81 +++++++------------------- 1 file changed, 20 insertions(+), 61 deletions(-) diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index d17db21c..433af6e2 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -32,16 +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, - isDeviceCodeFlow: false, - }); + const [state, setState] = useState(INITIAL_STATE); const abortControllerRef = useRef(null); const pollIntervalRef = useRef | null>(null); @@ -89,15 +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, - isDeviceCodeFlow: false, - }); + setState(INITIAL_STATE); } else if (data.status === 'error') { stopPolling(); const errorMsg = data.error || 'Authentication failed'; @@ -120,13 +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, - isDeviceCodeFlow: false, }); return; } @@ -167,16 +157,9 @@ export function useCliproxyAuthFlow() { if (response.ok && data.success) { 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, - isDeviceCodeFlow: false, - }); + // 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); @@ -238,15 +221,7 @@ export function useCliproxyAuthFlow() { } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - setState({ - provider: null, - isAuthenticating: false, - error: null, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, - isDeviceCodeFlow: false, - }); + setState(INITIAL_STATE); return; } const message = error instanceof Error ? error.message : 'Authentication failed'; @@ -265,15 +240,7 @@ export function useCliproxyAuthFlow() { const currentProvider = state.provider; abortControllerRef.current?.abort(); stopPolling(); - setState({ - provider: null, - isAuthenticating: false, - error: null, - authUrl: null, - oauthState: null, - isSubmittingCallback: false, - isDeviceCodeFlow: false, - }); + setState(INITIAL_STATE); // Also cancel on backend if (currentProvider) { api.cliproxy.auth.cancel(currentProvider).catch(() => { @@ -302,15 +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, - isDeviceCodeFlow: false, - }); + setState(INITIAL_STATE); } else { throw new Error(data.error || 'Callback submission failed'); }