mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 18:16:29 +00:00
fix(ghcp): display device code during OAuth device code flow
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
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
|||||||
import { ProviderOAuthConfig } from './auth-types';
|
import { ProviderOAuthConfig } from './auth-types';
|
||||||
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
import { getTimeoutTroubleshooting, showStep } from './environment-detector';
|
||||||
import { isAuthenticated, registerAccountFromToken } from './token-manager';
|
import { isAuthenticated, registerAccountFromToken } from './token-manager';
|
||||||
|
import { deviceCodeEvents, type DeviceCodePrompt } from '../device-code-handler';
|
||||||
|
|
||||||
/** Options for OAuth process execution */
|
/** Options for OAuth process execution */
|
||||||
export interface OAuthProcessOptions {
|
export interface OAuthProcessOptions {
|
||||||
@@ -46,6 +47,10 @@ interface ProcessState {
|
|||||||
accumulatedOutput: string;
|
accumulatedOutput: string;
|
||||||
parsedProjects: GCloudProject[];
|
parsedProjects: GCloudProject[];
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
/** Device code displayed to user (for Device Code Flow) */
|
||||||
|
deviceCodeDisplayed: boolean;
|
||||||
|
/** The user code to enter at verification URL */
|
||||||
|
userCode: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +104,8 @@ async function handleStdout(
|
|||||||
log(`stdout: ${output.trim()}`);
|
log(`stdout: ${output.trim()}`);
|
||||||
state.accumulatedOutput += output;
|
state.accumulatedOutput += output;
|
||||||
|
|
||||||
|
const isDeviceCodeFlow = options.callbackPort === null;
|
||||||
|
|
||||||
// Parse project list when available
|
// Parse project list when available
|
||||||
if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
|
if (isProjectList(state.accumulatedOutput) && state.parsedProjects.length === 0) {
|
||||||
state.parsedProjects = parseProjectList(state.accumulatedOutput);
|
state.parsedProjects = parseProjectList(state.accumulatedOutput);
|
||||||
@@ -111,16 +118,62 @@ async function handleStdout(
|
|||||||
await handleProjectSelection(output, state, options, authProcess, log);
|
await handleProjectSelection(output, state, options, authProcess, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect callback server / browser
|
// Handle Device Code Flow: parse and display user code
|
||||||
if (!state.browserOpened && (output.includes('listening') || output.includes('http'))) {
|
if (isDeviceCodeFlow && !state.deviceCodeDisplayed) {
|
||||||
|
// Parse device/user code from various formats:
|
||||||
|
// "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY"
|
||||||
|
const codeMatch = state.accumulatedOutput.match(
|
||||||
|
/(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i
|
||||||
|
);
|
||||||
|
const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i);
|
||||||
|
|
||||||
|
if (codeMatch) {
|
||||||
|
state.userCode = codeMatch[1].toUpperCase();
|
||||||
|
state.deviceCodeDisplayed = true;
|
||||||
|
log(`Parsed device code: ${state.userCode}`);
|
||||||
|
|
||||||
|
const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device';
|
||||||
|
|
||||||
|
// Emit device code event for WebSocket broadcast to UI
|
||||||
|
const deviceCodePrompt: DeviceCodePrompt = {
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
provider: options.provider,
|
||||||
|
userCode: state.userCode,
|
||||||
|
verificationUrl,
|
||||||
|
expiresAt: Date.now() + 900000, // 15 minutes
|
||||||
|
};
|
||||||
|
deviceCodeEvents.emit('deviceCode:received', deviceCodePrompt);
|
||||||
|
|
||||||
|
// Display device code prominently in CLI
|
||||||
|
console.log('');
|
||||||
|
console.log(' ╔══════════════════════════════════════════════════════╗');
|
||||||
|
console.log(` ║ Enter this code: ${state.userCode.padEnd(35)}║`);
|
||||||
|
console.log(' ╚══════════════════════════════════════════════════════╝');
|
||||||
|
console.log('');
|
||||||
|
console.log(info(`Open: ${verificationUrl}`));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// Update step display for device code flow
|
||||||
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
|
showStep(2, 4, 'ok', 'Device code received');
|
||||||
|
showStep(3, 4, 'progress', 'Waiting for authorization...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect callback server / browser (for Authorization Code flows only)
|
||||||
|
if (
|
||||||
|
!isDeviceCodeFlow &&
|
||||||
|
!state.browserOpened &&
|
||||||
|
(output.includes('listening') || output.includes('http'))
|
||||||
|
) {
|
||||||
process.stdout.write('\x1b[1A\x1b[2K');
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`);
|
showStep(2, 4, 'ok', `Callback server listening on port ${options.callbackPort}`);
|
||||||
showStep(3, 4, 'progress', 'Opening browser...');
|
showStep(3, 4, 'progress', 'Opening browser...');
|
||||||
state.browserOpened = true;
|
state.browserOpened = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display OAuth URLs in headless mode
|
// Display OAuth URLs in headless mode (for non-device-code flows)
|
||||||
if (options.headless && !state.urlDisplayed) {
|
if (!isDeviceCodeFlow && options.headless && !state.urlDisplayed) {
|
||||||
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
const urlMatch = output.match(/https?:\/\/[^\s]+/);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -218,6 +271,8 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
accumulatedOutput: '',
|
accumulatedOutput: '',
|
||||||
parsedProjects: [],
|
parsedProjects: [],
|
||||||
sessionId: generateSessionId(),
|
sessionId: generateSessionId(),
|
||||||
|
deviceCodeDisplayed: false,
|
||||||
|
userCode: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -236,16 +291,33 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Show waiting message after delay
|
// Show waiting message after delay
|
||||||
|
const isDeviceCodeFlow = callbackPort === null;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!state.browserOpened) {
|
if (isDeviceCodeFlow) {
|
||||||
process.stdout.write('\x1b[1A\x1b[2K');
|
// Device Code Flow: show polling message
|
||||||
showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
|
if (!state.deviceCodeDisplayed) {
|
||||||
showStep(3, 4, 'ok', 'Browser opened');
|
// Code not yet displayed, show generic waiting message
|
||||||
state.browserOpened = true;
|
showStep(3, 4, 'progress', 'Waiting for device code...');
|
||||||
|
}
|
||||||
|
showStep(4, 4, 'progress', 'Polling for authorization...');
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
info('Complete the login in your browser. This page will update automatically.')
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Authorization Code Flow: show callback server message
|
||||||
|
if (!state.browserOpened) {
|
||||||
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
|
showStep(2, 4, 'ok', `Callback server ready (port ${callbackPort})`);
|
||||||
|
showStep(3, 4, 'ok', 'Browser opened');
|
||||||
|
state.browserOpened = true;
|
||||||
|
}
|
||||||
|
showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
info('Complete the login in your browser. This page will update automatically.')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
showStep(4, 4, 'progress', 'Waiting for OAuth callback...');
|
|
||||||
console.log('');
|
|
||||||
console.log(info('Complete the login in your browser. This page will update automatically.'));
|
|
||||||
if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose'));
|
if (!verbose) console.log(info('If stuck, try: ccs ' + provider + ' --auth --verbose'));
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
@@ -269,12 +341,34 @@ export function executeOAuthProcess(options: OAuthProcessOptions): Promise<Accou
|
|||||||
if (isAuthenticated(provider)) {
|
if (isAuthenticated(provider)) {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(ok(`Authentication successful (${elapsed}s)`));
|
console.log(ok(`Authentication successful (${elapsed}s)`));
|
||||||
|
|
||||||
|
// Emit device code completion event for UI
|
||||||
|
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
||||||
|
deviceCodeEvents.emit('deviceCode:completed', state.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
resolve(registerAccountFromToken(provider, tokenDir, nickname));
|
resolve(registerAccountFromToken(provider, tokenDir, nickname));
|
||||||
} else {
|
} else {
|
||||||
|
// Emit device code failure event for UI
|
||||||
|
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
||||||
|
deviceCodeEvents.emit('deviceCode:failed', {
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
error: 'Token not found after authentication',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleTokenNotFound(provider, callbackPort);
|
handleTokenNotFound(provider, callbackPort);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Emit device code failure event for UI
|
||||||
|
if (isDeviceCodeFlow && state.deviceCodeDisplayed) {
|
||||||
|
deviceCodeEvents.emit('deviceCode:failed', {
|
||||||
|
sessionId: state.sessionId,
|
||||||
|
error: `Auth process exited with code ${code}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handleProcessError(code, state, headless);
|
handleProcessError(code, state, headless);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* Similar pattern to project-selection-handler.ts but for device code flows
|
||||||
|
* (GitHub Copilot, Qwen, etc.)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device code prompt data sent to UI
|
||||||
|
*/
|
||||||
|
export interface DeviceCodePrompt {
|
||||||
|
sessionId: string;
|
||||||
|
provider: string;
|
||||||
|
userCode: string;
|
||||||
|
verificationUrl: string;
|
||||||
|
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,
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
projectSelectionEvents,
|
projectSelectionEvents,
|
||||||
type ProjectSelectionPrompt,
|
type ProjectSelectionPrompt,
|
||||||
} from '../cliproxy/project-selection-handler';
|
} from '../cliproxy/project-selection-handler';
|
||||||
|
import { deviceCodeEvents, type DeviceCodePrompt } from '../cliproxy/device-code-handler';
|
||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -117,11 +118,54 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Listen for device code events and broadcast to clients
|
||||||
|
const handleDeviceCodeReceived = (prompt: DeviceCodePrompt): void => {
|
||||||
|
console.log(info(`[WS] Broadcasting device code (session: ${prompt.sessionId})`));
|
||||||
|
broadcast({
|
||||||
|
type: 'deviceCodeReceived',
|
||||||
|
...prompt,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeviceCodeCompleted = (sessionId: string): void => {
|
||||||
|
console.log(info(`[WS] Device code auth completed (session: ${sessionId})`));
|
||||||
|
broadcast({
|
||||||
|
type: 'deviceCodeCompleted',
|
||||||
|
sessionId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeviceCodeFailed = (data: { sessionId: string; error?: string }): void => {
|
||||||
|
console.log(info(`[WS] Device code auth failed (session: ${data.sessionId})`));
|
||||||
|
broadcast({
|
||||||
|
type: 'deviceCodeFailed',
|
||||||
|
...data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeviceCodeExpired = (sessionId: string): void => {
|
||||||
|
console.log(info(`[WS] Device code expired (session: ${sessionId})`));
|
||||||
|
broadcast({
|
||||||
|
type: 'deviceCodeExpired',
|
||||||
|
sessionId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Subscribe to project selection events
|
// Subscribe to project selection events
|
||||||
projectSelectionEvents.on('selection:required', handleProjectSelectionRequired);
|
projectSelectionEvents.on('selection:required', handleProjectSelectionRequired);
|
||||||
projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout);
|
projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout);
|
||||||
projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted);
|
projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted);
|
||||||
|
|
||||||
|
// Subscribe to device code events
|
||||||
|
deviceCodeEvents.on('deviceCode:received', handleDeviceCodeReceived);
|
||||||
|
deviceCodeEvents.on('deviceCode:completed', handleDeviceCodeCompleted);
|
||||||
|
deviceCodeEvents.on('deviceCode:failed', handleDeviceCodeFailed);
|
||||||
|
deviceCodeEvents.on('deviceCode:expired', handleDeviceCodeExpired);
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
watcher.close();
|
watcher.close();
|
||||||
@@ -131,6 +175,12 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout);
|
projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout);
|
||||||
projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted);
|
projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted);
|
||||||
|
|
||||||
|
// Unsubscribe from device code events
|
||||||
|
deviceCodeEvents.off('deviceCode:received', handleDeviceCodeReceived);
|
||||||
|
deviceCodeEvents.off('deviceCode:completed', handleDeviceCodeCompleted);
|
||||||
|
deviceCodeEvents.off('deviceCode:failed', handleDeviceCodeFailed);
|
||||||
|
deviceCodeEvents.off('deviceCode:expired', handleDeviceCodeExpired);
|
||||||
|
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
client.close(1001, 'Server shutting down');
|
client.close(1001, 'Server shutting down');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { ClaudeKitBadge } from '@/components/shared/claudekit-badge';
|
import { ClaudeKitBadge } from '@/components/shared/claudekit-badge';
|
||||||
import { SponsorButton } from '@/components/shared/sponsor-button';
|
import { SponsorButton } from '@/components/shared/sponsor-button';
|
||||||
import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog';
|
import { ProjectSelectionDialog } from '@/components/shared/project-selection-dialog';
|
||||||
|
import { DeviceCodeDialog } from '@/components/shared/device-code-dialog';
|
||||||
import { useProjectSelection } from '@/hooks/use-project-selection';
|
import { useProjectSelection } from '@/hooks/use-project-selection';
|
||||||
|
import { useDeviceCode } from '@/hooks/use-device-code';
|
||||||
|
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
return (
|
return (
|
||||||
@@ -25,6 +27,7 @@ function PageLoader() {
|
|||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
|
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
|
||||||
|
const deviceCode = useDeviceCode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
@@ -64,6 +67,19 @@ export function Layout() {
|
|||||||
onSelect={onSelect}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Global device code dialog for Device Code OAuth flows (GitHub Copilot, Qwen) */}
|
||||||
|
{deviceCode.prompt && (
|
||||||
|
<DeviceCodeDialog
|
||||||
|
open={deviceCode.isOpen}
|
||||||
|
onClose={deviceCode.onClose}
|
||||||
|
sessionId={deviceCode.prompt.sessionId}
|
||||||
|
provider={deviceCode.prompt.provider}
|
||||||
|
userCode={deviceCode.prompt.userCode}
|
||||||
|
verificationUrl={deviceCode.prompt.verificationUrl}
|
||||||
|
expiresAt={deviceCode.prompt.expiresAt}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Device Code Dialog Component
|
||||||
|
*
|
||||||
|
* Displays during OAuth Device Code flow when user needs to enter
|
||||||
|
* a verification code at the provider's website (e.g., GitHub, Qwen).
|
||||||
|
* Shows prominently formatted code with copy and open URL buttons.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface DeviceCodeDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
sessionId: string;
|
||||||
|
provider: string;
|
||||||
|
userCode: string;
|
||||||
|
verificationUrl: string;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider display names */
|
||||||
|
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||||
|
ghcp: 'GitHub Copilot',
|
||||||
|
qwen: 'Qwen Code',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Provider specific instructions */
|
||||||
|
const PROVIDER_INSTRUCTIONS: Record<string, string> = {
|
||||||
|
ghcp: 'Sign in with your GitHub account that has Copilot access.',
|
||||||
|
qwen: 'Sign in with your Qwen account to authorize access.',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DeviceCodeDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
userCode,
|
||||||
|
verificationUrl,
|
||||||
|
expiresAt,
|
||||||
|
}: DeviceCodeDialogProps) {
|
||||||
|
const [hasCopied, setHasCopied] = useState(false);
|
||||||
|
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Calculate and update remaining time
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const updateTime = () => {
|
||||||
|
const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
||||||
|
setTimeRemaining(remaining);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateTime();
|
||||||
|
const timer = setInterval(updateTime, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [open, expiresAt]);
|
||||||
|
|
||||||
|
const handleCopyCode = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(userCode);
|
||||||
|
setHasCopied(true);
|
||||||
|
toast.success('Code copied to clipboard');
|
||||||
|
setTimeout(() => setHasCopied(false), 2000);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy code');
|
||||||
|
}
|
||||||
|
}, [userCode]);
|
||||||
|
|
||||||
|
const handleOpenUrl = useCallback(() => {
|
||||||
|
window.open(verificationUrl, '_blank', 'noopener,noreferrer');
|
||||||
|
}, [verificationUrl]);
|
||||||
|
|
||||||
|
const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider;
|
||||||
|
const instructions =
|
||||||
|
PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.';
|
||||||
|
|
||||||
|
// Format remaining time
|
||||||
|
const formatTime = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suppress unused variable warning - sessionId used for identification
|
||||||
|
void sessionId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<KeyRound className="w-5 h-5" />
|
||||||
|
Authorize {providerDisplay}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the code below at the authorization page.
|
||||||
|
{timeRemaining !== null && timeRemaining > 0 && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
(Expires in {formatTime(timeRemaining)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Large code display */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-muted rounded-lg p-6 text-center">
|
||||||
|
<div className="text-4xl font-mono font-bold tracking-[0.3em] text-foreground select-all">
|
||||||
|
{userCode}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
onClick={handleCopyCode}
|
||||||
|
>
|
||||||
|
{hasCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="text-sm text-muted-foreground text-center">
|
||||||
|
<p>{instructions}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Button onClick={handleOpenUrl} className="w-full">
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
Open {providerDisplay.split(' ')[0]}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleCopyCode} className="w-full">
|
||||||
|
{hasCopied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||||
|
Copied!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Copy Code
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waiting indicator */}
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Waiting for authorization...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export { CodeEditor } from './code-editor';
|
|||||||
export { CommandBuilder } from './command-builder';
|
export { CommandBuilder } from './command-builder';
|
||||||
export { ConfirmDialog } from './confirm-dialog';
|
export { ConfirmDialog } from './confirm-dialog';
|
||||||
export { ConnectionIndicator } from './connection-indicator';
|
export { ConnectionIndicator } from './connection-indicator';
|
||||||
|
export { DeviceCodeDialog } from './device-code-dialog';
|
||||||
export { DocsLink } from './docs-link';
|
export { DocsLink } from './docs-link';
|
||||||
export { GitHubLink } from './github-link';
|
export { GitHubLink } from './github-link';
|
||||||
export { GlobalEnvIndicator } from './global-env-indicator';
|
export { GlobalEnvIndicator } from './global-env-indicator';
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* 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]
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user