refactor(ghcp): remove unused device code session management

- Remove unused functions: registerDeviceCode, completeDeviceCode,
  failDeviceCode, getActiveDeviceCode, hasActiveDeviceCode,
  getAllActiveDeviceCodes, ActiveSession interface, activeSessions Map
- Reduce device-code-handler.ts from 168 to 33 lines (YAGNI)
- Add ARIA labels to copy button for accessibility
- Fix timer to stop at 0 seconds instead of continuing negative
- Add "(Code expired)" visual indicator with destructive styling
- Change void sessionId to data-session-id attribute for debugging

Code review findings applied. Gemini OAuth unaffected (uses separate
project-selection-handler.ts with Authorization Code Flow).
This commit is contained in:
kaitranntt
2025-12-24 03:15:57 -05:00
parent 46f1699b1c
commit 5de6cccee0
2 changed files with 24 additions and 149 deletions
+8 -143
View File
@@ -2,11 +2,14 @@
* Device Code Handler * Device Code Handler
* *
* Manages device code display prompts during OAuth Device Code flow. * Manages device code display prompts during OAuth Device Code flow.
* Parses CLIProxyAPI stdout for device codes and broadcasts via WebSocket * Broadcasts device code events via WebSocket to both CLI terminal
* to both CLI terminal and Web UI (ccs config). * and Web UI (ccs config).
* *
* Similar pattern to project-selection-handler.ts but for device code flows * Events emitted by oauth-process.ts:
* (GitHub Copilot, Qwen, etc.) * - deviceCode:received - When device code is parsed from output
* - deviceCode:completed - When auth succeeds
* - deviceCode:failed - When auth fails
* - deviceCode:expired - When code expires (handled by UI timer)
*/ */
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
@@ -22,146 +25,8 @@ export interface DeviceCodePrompt {
expiresAt: number; expiresAt: number;
} }
/**
* Active device code session with completion resolver
*/
interface ActiveSession {
prompt: DeviceCodePrompt;
resolve: (success: boolean) => void;
timeout: NodeJS.Timeout;
}
// Global event emitter for device code events // Global event emitter for device code events
export const deviceCodeEvents = new EventEmitter(); export const deviceCodeEvents = new EventEmitter();
// Active sessions by session ID
const activeSessions = new Map<string, ActiveSession>();
// Default timeout for device code (15 minutes - GitHub's default) // Default timeout for device code (15 minutes - GitHub's default)
const DEVICE_CODE_TIMEOUT_MS = 900000; export const DEVICE_CODE_TIMEOUT_MS = 900000;
/**
* Parse device/user code from CLIProxy output
*
* Supports various formats:
* - "Enter code: XXXX-YYYY"
* - "code XXXX-YYYY"
* - "user code: XXXX-YYYY"
* - "code \"XXXX-YYYY\""
*/
export function parseDeviceCode(output: string): string | null {
const codeMatch = output.match(
/(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i
);
return codeMatch ? codeMatch[1].toUpperCase() : null;
}
/**
* Parse verification URL from CLIProxy output
*/
export function parseVerificationUrl(output: string): string | null {
const urlMatch = output.match(/(https?:\/\/[^\s]+device[^\s]*)/i);
return urlMatch ? urlMatch[1] : null;
}
/**
* Register a device code session and emit event for UI broadcast
*
* @param prompt - Device code prompt data
* @returns Promise that resolves when auth completes or times out
*/
export function registerDeviceCode(prompt: DeviceCodePrompt): Promise<boolean> {
return new Promise((resolve) => {
// Set timeout for session expiry
const timeout = setTimeout(() => {
const session = activeSessions.get(prompt.sessionId);
if (session) {
activeSessions.delete(prompt.sessionId);
deviceCodeEvents.emit('deviceCode:expired', prompt.sessionId);
resolve(false);
}
}, DEVICE_CODE_TIMEOUT_MS);
// Store active session
activeSessions.set(prompt.sessionId, {
prompt,
resolve,
timeout,
});
// Emit event for WebSocket broadcast to UI
deviceCodeEvents.emit('deviceCode:received', prompt);
});
}
/**
* Mark device code session as completed (auth successful)
*/
export function completeDeviceCode(sessionId: string): boolean {
const session = activeSessions.get(sessionId);
if (!session) {
return false;
}
clearTimeout(session.timeout);
activeSessions.delete(sessionId);
session.resolve(true);
deviceCodeEvents.emit('deviceCode:completed', sessionId);
return true;
}
/**
* Mark device code session as failed
*/
export function failDeviceCode(sessionId: string, error?: string): boolean {
const session = activeSessions.get(sessionId);
if (!session) {
return false;
}
clearTimeout(session.timeout);
activeSessions.delete(sessionId);
session.resolve(false);
deviceCodeEvents.emit('deviceCode:failed', { sessionId, error });
return true;
}
/**
* Get active device code prompt
*/
export function getActiveDeviceCode(sessionId: string): DeviceCodePrompt | null {
const session = activeSessions.get(sessionId);
return session ? session.prompt : null;
}
/**
* Check if there's an active device code session
*/
export function hasActiveDeviceCode(sessionId: string): boolean {
return activeSessions.has(sessionId);
}
/**
* Get all active device code sessions (for debugging/status)
*/
export function getAllActiveDeviceCodes(): DeviceCodePrompt[] {
return Array.from(activeSessions.values()).map((s) => s.prompt);
}
export default {
parseDeviceCode,
parseVerificationUrl,
registerDeviceCode,
completeDeviceCode,
failDeviceCode,
getActiveDeviceCode,
hasActiveDeviceCode,
getAllActiveDeviceCodes,
deviceCodeEvents,
};
@@ -52,19 +52,28 @@ export function DeviceCodeDialog({
const [hasCopied, setHasCopied] = useState(false); const [hasCopied, setHasCopied] = useState(false);
const [timeRemaining, setTimeRemaining] = useState<number | null>(null); const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
// Calculate and update remaining time // Calculate and update remaining time, stop at 0
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
let timer: ReturnType<typeof setInterval> | null = null;
const updateTime = () => { const updateTime = () => {
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
setTimeRemaining(remaining); setTimeRemaining(remaining);
// Stop timer when expired
if (remaining === 0 && timer) {
clearInterval(timer);
timer = null;
}
}; };
updateTime(); updateTime();
const timer = setInterval(updateTime, 1000); timer = setInterval(updateTime, 1000);
return () => clearInterval(timer); return () => {
if (timer) clearInterval(timer);
};
}, [open, expiresAt]); }, [open, expiresAt]);
const handleCopyCode = useCallback(async () => { const handleCopyCode = useCallback(async () => {
@@ -93,12 +102,11 @@ export function DeviceCodeDialog({
return `${mins}:${secs.toString().padStart(2, '0')}`; return `${mins}:${secs.toString().padStart(2, '0')}`;
}; };
// Suppress unused variable warning - sessionId used for identification const isExpired = timeRemaining === 0;
void sessionId;
return ( return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md" data-session-id={sessionId}>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5" /> <KeyRound className="w-5 h-5" />
@@ -111,6 +119,7 @@ export function DeviceCodeDialog({
(Expires in {formatTime(timeRemaining)}) (Expires in {formatTime(timeRemaining)})
</span> </span>
)} )}
{isExpired && <span className="text-destructive ml-1 font-medium">(Code expired)</span>}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -127,6 +136,7 @@ export function DeviceCodeDialog({
size="icon" size="icon"
className="absolute top-2 right-2" className="absolute top-2 right-2"
onClick={handleCopyCode} onClick={handleCopyCode}
aria-label={hasCopied ? 'Code copied' : 'Copy verification code'}
> >
{hasCopied ? ( {hasCopied ? (
<Check className="h-4 w-4 text-green-500" /> <Check className="h-4 w-4 text-green-500" />