From 13f6c3f14bd0d1c920e339b2486dcd6e37ce50f4 Mon Sep 17 00:00:00 2001
From: kaitranntt
Date: Thu, 5 Feb 2026 15:27:30 -0500
Subject: [PATCH] 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);
+}