Merge pull request #469 from kaitranntt/fix/460-ghcp-device-code-display

fix(ui): display device code for GitHub Copilot OAuth in Dashboard
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-05 15:51:29 -05:00
committed by GitHub
3 changed files with 119 additions and 80 deletions
@@ -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({
<DialogDescription>
{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.'}
</DialogDescription>
</DialogHeader>
@@ -180,17 +185,19 @@ export function AddAccountDialog({
Waiting for authentication...
</p>
<p className="text-xs text-muted-foreground mt-1">
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.'}
</p>
</div>
{/* Error from /start-url - fallback URL not available */}
{/* Error display */}
{authFlow.error && !authFlow.authUrl && (
<p className="text-xs text-center text-destructive">{authFlow.error}</p>
)}
{/* 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 && (
<div className="space-y-3">
<div className="space-y-2">
<Label className="text-xs">Open this URL in any browser to sign in:</Label>
+95 -74
View File
@@ -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<AuthFlowState>({
provider: null,
isAuthenticating: false,
error: null,
authUrl: null,
oauthState: null,
isSubmittingCallback: false,
});
const [state, setState] = useState<AuthFlowState>(INITIAL_STATE);
const abortControllerRef = useRef<AbortController | null>(null);
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | 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');
}
+11
View File
@@ -71,3 +71,14 @@ const PROVIDER_NAMES: Record<string, string> = {
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);
}