mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
When authenticating with GitHub Copilot using device code flow, the user code was not being displayed. This fix: - Parses device codes from CLIProxy output in oauth-process.ts - Displays code prominently in CLI terminal with box formatting - Broadcasts device code events via WebSocket for UI display - Creates DeviceCodeDialog component for web UI (ccs config) - Follows same pattern as Gemini project selection dialog Closes #189
135 lines
4.3 KiB
TypeScript
135 lines
4.3 KiB
TypeScript
/**
|
|
* Device Code Hook
|
|
*
|
|
* Listens for WebSocket device code events and manages dialog state.
|
|
* Similar to useProjectSelection but for Device Code OAuth flows
|
|
* (GitHub Copilot, Qwen, etc.)
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
export interface DeviceCodePrompt {
|
|
sessionId: string;
|
|
provider: string;
|
|
userCode: string;
|
|
verificationUrl: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface DeviceCodeState {
|
|
isOpen: boolean;
|
|
prompt: DeviceCodePrompt | null;
|
|
error: string | null;
|
|
}
|
|
|
|
/** Provider display names for user-friendly messages */
|
|
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
|
ghcp: 'GitHub Copilot',
|
|
qwen: 'Qwen Code',
|
|
};
|
|
|
|
export function useDeviceCode() {
|
|
const [state, setState] = useState<DeviceCodeState>({
|
|
isOpen: false,
|
|
prompt: null,
|
|
error: null,
|
|
});
|
|
|
|
// Listen for WebSocket messages via custom events
|
|
useEffect(() => {
|
|
const handleMessage = (event: CustomEvent<{ type: string; [key: string]: unknown }>) => {
|
|
const data = event.detail;
|
|
|
|
if (data.type === 'deviceCodeReceived') {
|
|
console.log('[DeviceCode] Received prompt:', data.sessionId);
|
|
const displayName = PROVIDER_DISPLAY_NAMES[data.provider as string] || data.provider;
|
|
toast.info(`${displayName} authorization required`);
|
|
|
|
setState({
|
|
isOpen: true,
|
|
prompt: {
|
|
sessionId: data.sessionId as string,
|
|
provider: data.provider as string,
|
|
userCode: data.userCode as string,
|
|
verificationUrl: data.verificationUrl as string,
|
|
expiresAt: data.expiresAt as number,
|
|
},
|
|
error: null,
|
|
});
|
|
} else if (data.type === 'deviceCodeCompleted') {
|
|
console.log('[DeviceCode] Auth completed:', data.sessionId);
|
|
setState((prev) => {
|
|
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
|
|
const displayName =
|
|
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
|
|
toast.success(`${displayName} authentication successful!`);
|
|
return { isOpen: false, prompt: null, error: null };
|
|
}
|
|
return prev;
|
|
});
|
|
} else if (data.type === 'deviceCodeFailed') {
|
|
console.log('[DeviceCode] Auth failed:', data.sessionId, data.error);
|
|
setState((prev) => {
|
|
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
|
|
const displayName =
|
|
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
|
|
toast.error(`${displayName} authentication failed`);
|
|
return { isOpen: false, prompt: null, error: data.error as string };
|
|
}
|
|
return prev;
|
|
});
|
|
} else if (data.type === 'deviceCodeExpired') {
|
|
console.log('[DeviceCode] Code expired:', data.sessionId);
|
|
setState((prev) => {
|
|
if (prev.prompt?.sessionId === data.sessionId) {
|
|
toast.error('Device code expired. Please try again.');
|
|
return { isOpen: false, prompt: null, error: 'Device code expired' };
|
|
}
|
|
return prev;
|
|
});
|
|
}
|
|
};
|
|
|
|
// Listen for custom ws-message events dispatched by useWebSocket
|
|
window.addEventListener('ws-message', handleMessage as EventListener);
|
|
|
|
return () => {
|
|
window.removeEventListener('ws-message', handleMessage as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
const handleClose = useCallback(() => {
|
|
setState({ isOpen: false, prompt: null, error: null });
|
|
}, []);
|
|
|
|
const handleOpenUrl = useCallback(() => {
|
|
if (state.prompt?.verificationUrl) {
|
|
window.open(state.prompt.verificationUrl, '_blank', 'noopener,noreferrer');
|
|
}
|
|
}, [state.prompt]);
|
|
|
|
const handleCopyCode = useCallback(async () => {
|
|
if (state.prompt?.userCode) {
|
|
try {
|
|
await navigator.clipboard.writeText(state.prompt.userCode);
|
|
toast.success('Code copied to clipboard');
|
|
} catch {
|
|
toast.error('Failed to copy code');
|
|
}
|
|
}
|
|
}, [state.prompt]);
|
|
|
|
return useMemo(
|
|
() => ({
|
|
isOpen: state.isOpen,
|
|
prompt: state.prompt,
|
|
error: state.error,
|
|
onClose: handleClose,
|
|
onOpenUrl: handleOpenUrl,
|
|
onCopyCode: handleCopyCode,
|
|
}),
|
|
[state.isOpen, state.prompt, state.error, handleClose, handleOpenUrl, handleCopyCode]
|
|
);
|
|
}
|