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
This commit is contained in:
kaitranntt
2026-02-05 15:27:30 -05:00
parent 237f91ff69
commit 13f6c3f14b
3 changed files with 115 additions and 35 deletions
@@ -1,7 +1,8 @@
/** /**
* Add Account Dialog Component * Add Account Dialog Component
* Uses /start-url to get OAuth URL + polls for completion via management API. * 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. * Shows auth URL + callback paste field. Polling auto-closes on success.
* For Kiro: Also shows "Import from IDE" option. * 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 { useKiroImport } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { applyDefaultPreset } from '@/lib/preset-utils'; import { applyDefaultPreset } from '@/lib/preset-utils';
import { isDeviceCodeProvider } from '@/lib/provider-config';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface AddAccountDialogProps { interface AddAccountDialogProps {
@@ -47,6 +49,7 @@ export function AddAccountDialog({
const kiroImportMutation = useKiroImport(); const kiroImportMutation = useKiroImport();
const isKiro = provider === 'kiro'; const isKiro = provider === 'kiro';
const isDeviceCode = isDeviceCodeProvider(provider);
const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending;
const resetAndClose = () => { const resetAndClose = () => {
@@ -144,7 +147,9 @@ export function AddAccountDialog({
<DialogDescription> <DialogDescription>
{isKiro {isKiro
? 'Authenticate via browser or import an existing token from Kiro IDE.' ? '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> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -180,17 +185,19 @@ export function AddAccountDialog({
Waiting for authentication... Waiting for authentication...
</p> </p>
<p className="text-xs text-muted-foreground mt-1"> <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> </p>
</div> </div>
{/* Error from /start-url - fallback URL not available */} {/* Error display */}
{authFlow.error && !authFlow.authUrl && ( {authFlow.error && !authFlow.authUrl && (
<p className="text-xs text-center text-destructive">{authFlow.error}</p> <p className="text-xs text-center text-destructive">{authFlow.error}</p>
)} )}
{/* Auth URL section - appears once /start-url returns */} {/* Auth URL section - only for Authorization Code flows, NOT Device Code */}
{authFlow.authUrl && ( {authFlow.authUrl && !authFlow.isDeviceCodeFlow && (
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs">Open this URL in any browser to sign in:</Label> <Label className="text-xs">Open this URL in any browser to sign in:</Label>
+91 -29
View File
@@ -7,7 +7,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
import { isValidProvider } from '@/lib/provider-config'; import { isValidProvider, isDeviceCodeProvider } from '@/lib/provider-config';
interface AuthFlowState { interface AuthFlowState {
provider: string | null; provider: string | null;
@@ -19,6 +19,8 @@ interface AuthFlowState {
oauthState: string | null; oauthState: string | null;
/** Whether callback is being submitted */ /** Whether callback is being submitted */
isSubmittingCallback: boolean; isSubmittingCallback: boolean;
/** Whether this is a device code flow (ghcp, qwen) - dialog handled separately via WebSocket */
isDeviceCodeFlow: boolean;
} }
interface StartAuthOptions { interface StartAuthOptions {
@@ -38,6 +40,7 @@ export function useCliproxyAuthFlow() {
authUrl: null, authUrl: null,
oauthState: null, oauthState: null,
isSubmittingCallback: false, isSubmittingCallback: false,
isDeviceCodeFlow: false,
}); });
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@@ -93,6 +96,7 @@ export function useCliproxyAuthFlow() {
authUrl: null, authUrl: null,
oauthState: null, oauthState: null,
isSubmittingCallback: false, isSubmittingCallback: false,
isDeviceCodeFlow: false,
}); });
} else if (data.status === 'error') { } else if (data.status === 'error') {
stopPolling(); stopPolling();
@@ -122,6 +126,7 @@ export function useCliproxyAuthFlow() {
authUrl: null, authUrl: null,
oauthState: null, oauthState: null,
isSubmittingCallback: false, isSubmittingCallback: false,
isDeviceCodeFlow: false,
}); });
return; return;
} }
@@ -134,6 +139,8 @@ export function useCliproxyAuthFlow() {
const controller = new AbortController(); const controller = new AbortController();
abortControllerRef.current = controller; abortControllerRef.current = controller;
const deviceCodeFlow = isDeviceCodeProvider(provider);
setState({ setState({
provider, provider,
isAuthenticating: true, isAuthenticating: true,
@@ -141,41 +148,93 @@ export function useCliproxyAuthFlow() {
authUrl: null, authUrl: null,
oauthState: null, oauthState: null,
isSubmittingCallback: false, isSubmittingCallback: false,
isDeviceCodeFlow: deviceCodeFlow,
}); });
try { try {
// Call start-url to get auth URL immediately (non-blocking) if (deviceCodeFlow) {
const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, { // Device Code Flow: Call /start endpoint which spawns CLIProxyAPI binary.
method: 'POST', // This emits WebSocket events with userCode that DeviceCodeDialog will display.
headers: { 'Content-Type': 'application/json' }, // The /start endpoint blocks until completion, so we don't await it here.
body: JSON.stringify({ nickname: options?.nickname }), fetch(`/api/cliproxy/auth/${provider}/start`, {
signal: controller.signal, 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) { if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to start OAuth'); throw new Error(data.error || 'Failed to start OAuth');
} }
// Update state with auth URL // Update state with auth URL
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
authUrl: data.authUrl, authUrl: data.authUrl,
oauthState: data.state, oauthState: data.state,
})); }));
// Auto-open auth URL in new browser tab (fallback URL still shown in dialog) // Auto-open auth URL in new browser tab (fallback URL still shown in dialog)
if (data.authUrl) { if (data.authUrl) {
window.open(data.authUrl, '_blank'); window.open(data.authUrl, '_blank');
} }
// Start polling for completion // Start polling for completion
if (data.state) { if (data.state) {
pollStartRef.current = Date.now(); pollStartRef.current = Date.now();
pollIntervalRef.current = setInterval(() => { pollIntervalRef.current = setInterval(() => {
pollStatus(provider, data.state); pollStatus(provider, data.state);
}, POLL_INTERVAL); }, POLL_INTERVAL);
}
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
@@ -186,6 +245,7 @@ export function useCliproxyAuthFlow() {
authUrl: null, authUrl: null,
oauthState: null, oauthState: null,
isSubmittingCallback: false, isSubmittingCallback: false,
isDeviceCodeFlow: false,
}); });
return; return;
} }
@@ -198,7 +258,7 @@ export function useCliproxyAuthFlow() {
})); }));
} }
}, },
[pollStatus, stopPolling] [pollStatus, stopPolling, queryClient]
); );
const cancelAuth = useCallback(() => { const cancelAuth = useCallback(() => {
@@ -212,6 +272,7 @@ export function useCliproxyAuthFlow() {
authUrl: null, authUrl: null,
oauthState: null, oauthState: null,
isSubmittingCallback: false, isSubmittingCallback: false,
isDeviceCodeFlow: false,
}); });
// Also cancel on backend // Also cancel on backend
if (currentProvider) { if (currentProvider) {
@@ -248,6 +309,7 @@ export function useCliproxyAuthFlow() {
authUrl: null, authUrl: null,
oauthState: null, oauthState: null,
isSubmittingCallback: false, isSubmittingCallback: false,
isDeviceCodeFlow: false,
}); });
} else { } else {
throw new Error(data.error || 'Callback submission failed'); 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 { export function getProviderDisplayName(provider: string): string {
return PROVIDER_NAMES[provider.toLowerCase()] || provider; 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);
}