mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 02:17:11 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user