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 && (
Open this URL in any browser to sign in:
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);
+}