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
*
* Manages device code display prompts during OAuth Device Code flow.
* Parses CLIProxyAPI stdout for device codes and broadcasts via WebSocket
* to both CLI terminal and Web UI (ccs config).
* Broadcasts device code events via WebSocket to both CLI terminal
* and Web UI (ccs config).
*
* Similar pattern to project-selection-handler.ts but for device code flows
* (GitHub Copilot, Qwen, etc.)
* Events emitted by oauth-process.ts:
* - 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';
@@ -22,146 +25,8 @@ export interface DeviceCodePrompt {
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
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)
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,
};
export const DEVICE_CODE_TIMEOUT_MS = 900000;
@@ -52,19 +52,28 @@ export function DeviceCodeDialog({
const [hasCopied, setHasCopied] = useState(false);
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
// Calculate and update remaining time
// Calculate and update remaining time, stop at 0
useEffect(() => {
if (!open) return;
let timer: ReturnType<typeof setInterval> | null = null;
const updateTime = () => {
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
setTimeRemaining(remaining);
// Stop timer when expired
if (remaining === 0 && timer) {
clearInterval(timer);
timer = null;
}
};
updateTime();
const timer = setInterval(updateTime, 1000);
timer = setInterval(updateTime, 1000);
return () => clearInterval(timer);
return () => {
if (timer) clearInterval(timer);
};
}, [open, expiresAt]);
const handleCopyCode = useCallback(async () => {
@@ -93,12 +102,11 @@ export function DeviceCodeDialog({
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Suppress unused variable warning - sessionId used for identification
void sessionId;
const isExpired = timeRemaining === 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-md" data-session-id={sessionId}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5" />
@@ -111,6 +119,7 @@ export function DeviceCodeDialog({
(Expires in {formatTime(timeRemaining)})
</span>
)}
{isExpired && <span className="text-destructive ml-1 font-medium">(Code expired)</span>}
</DialogDescription>
</DialogHeader>
@@ -127,6 +136,7 @@ export function DeviceCodeDialog({
size="icon"
className="absolute top-2 right-2"
onClick={handleCopyCode}
aria-label={hasCopied ? 'Code copied' : 'Copy verification code'}
>
{hasCopied ? (
<Check className="h-4 w-4 text-green-500" />