mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
feat(cliproxy): implement interactive project selection for OAuth flows
- Add ProjectSelectionHandler to parse and handle CLIProxy project prompts - Integrate project selection into AuthHandler with UI-first support - Add project selection API endpoints and WebSocket events - Implement ProjectSelectionDialog and useProjectSelection hook in UI - Fixes macOS OAuth timeout by handling interactive project selection via UI
This commit is contained in:
1 parent
a87ef973fc
commit
a66abba174
8 files changed
+714
-17
No files matched your search
@@ -32,6 +32,16 @@ import {
|
|||||||
enhancedPreflightOAuthCheck,
|
enhancedPreflightOAuthCheck,
|
||||||
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
||||||
} from '../management/oauth-port-diagnostics';
|
} from '../management/oauth-port-diagnostics';
|
||||||
|
import {
|
||||||
|
parseProjectList,
|
||||||
|
parseDefaultProject,
|
||||||
|
isProjectSelectionPrompt,
|
||||||
|
isProjectList,
|
||||||
|
generateSessionId,
|
||||||
|
requestProjectSelection,
|
||||||
|
type GCloudProject,
|
||||||
|
type ProjectSelectionPrompt,
|
||||||
|
} from './project-selection-handler';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
* OAuth callback ports used by CLIProxyAPI (hardcoded in binary)
|
||||||
@@ -451,20 +461,8 @@ function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | nu
|
|||||||
lines.push(' 1. Check browser completed auth (should show success page)');
|
lines.push(' 1. Check browser completed auth (should show success page)');
|
||||||
|
|
||||||
if (port) {
|
if (port) {
|
||||||
if (process.platform === 'win32') {
|
lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
|
||||||
lines.push(` 2. Verify port ${port} is reachable: curl http://localhost:${port}`);
|
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
||||||
lines.push(' 3. Windows Firewall may block - run as Administrator:');
|
|
||||||
lines.push(
|
|
||||||
` netsh advfirewall firewall add rule name="CCS OAuth" dir=in action=allow protocol=TCP localport=${port}`
|
|
||||||
);
|
|
||||||
lines.push(` 4. Try: ccs ${provider} --auth --verbose`);
|
|
||||||
} else if (process.platform === 'darwin') {
|
|
||||||
lines.push(` 2. Check for port conflicts: lsof -ti:${port}`);
|
|
||||||
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
|
||||||
} else {
|
|
||||||
lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
|
|
||||||
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
lines.push(` 2. Try: ccs ${provider} --auth --verbose`);
|
lines.push(` 2. Try: ccs ${provider} --auth --verbose`);
|
||||||
}
|
}
|
||||||
@@ -489,11 +487,14 @@ export async function triggerOAuth(
|
|||||||
account?: string;
|
account?: string;
|
||||||
add?: boolean;
|
add?: boolean;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
/** If true, triggered from Web UI (enables project selection prompt) */
|
||||||
|
fromUI?: boolean;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<AccountInfo | null> {
|
): Promise<AccountInfo | null> {
|
||||||
const oauthConfig = getOAuthConfig(provider);
|
const oauthConfig = getOAuthConfig(provider);
|
||||||
const { verbose = false, add = false, nickname } = options;
|
const { verbose = false, add = false, nickname, fromUI = false } = options;
|
||||||
const callbackPort = OAUTH_PORTS[provider];
|
const callbackPort = OAUTH_PORTS[provider];
|
||||||
|
const isCLI = !fromUI; // CLI mode = auto-select default project
|
||||||
|
|
||||||
// Auto-detect headless if not explicitly set
|
// Auto-detect headless if not explicitly set
|
||||||
const headless = options.headless ?? isHeadlessEnvironment();
|
const headless = options.headless ?? isHeadlessEnvironment();
|
||||||
@@ -620,8 +621,9 @@ export async function triggerOAuth(
|
|||||||
|
|
||||||
return new Promise<AccountInfo | null>((resolve) => {
|
return new Promise<AccountInfo | null>((resolve) => {
|
||||||
// Spawn CLIProxyAPI with auth flag
|
// Spawn CLIProxyAPI with auth flag
|
||||||
|
// Use pipe for stdin to auto-respond to interactive prompts (e.g., project selection)
|
||||||
const authProcess = spawn(binaryPath, args, {
|
const authProcess = spawn(binaryPath, args, {
|
||||||
stdio: ['inherit', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLI_PROXY_AUTH_DIR: tokenDir,
|
CLI_PROXY_AUTH_DIR: tokenDir,
|
||||||
@@ -631,12 +633,63 @@ export async function triggerOAuth(
|
|||||||
let stderrData = '';
|
let stderrData = '';
|
||||||
let urlDisplayed = false;
|
let urlDisplayed = false;
|
||||||
let browserOpened = false;
|
let browserOpened = false;
|
||||||
|
let projectPromptHandled = false;
|
||||||
|
let accumulatedOutput = ''; // Accumulate output to parse project list
|
||||||
|
let parsedProjects: GCloudProject[] = [];
|
||||||
|
const sessionId = generateSessionId(); // Unique session ID for this auth flow
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
authProcess.stdout?.on('data', (data: Buffer) => {
|
authProcess.stdout?.on('data', async (data: Buffer) => {
|
||||||
const output = data.toString();
|
const output = data.toString();
|
||||||
log(`stdout: ${output.trim()}`);
|
log(`stdout: ${output.trim()}`);
|
||||||
|
|
||||||
|
// Accumulate output for project list parsing
|
||||||
|
accumulatedOutput += output;
|
||||||
|
|
||||||
|
// Parse project list when available
|
||||||
|
if (isProjectList(accumulatedOutput) && parsedProjects.length === 0) {
|
||||||
|
parsedProjects = parseProjectList(accumulatedOutput);
|
||||||
|
log(`Parsed ${parsedProjects.length} projects`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle project selection prompt
|
||||||
|
if (!projectPromptHandled && isProjectSelectionPrompt(output)) {
|
||||||
|
projectPromptHandled = true;
|
||||||
|
|
||||||
|
const defaultProjectId = parseDefaultProject(output) || '';
|
||||||
|
|
||||||
|
// If we have projects and this is a UI-triggered flow, request selection
|
||||||
|
if (parsedProjects.length > 0 && !isCLI) {
|
||||||
|
log(`Requesting project selection from UI (session: ${sessionId})`);
|
||||||
|
|
||||||
|
const prompt: ProjectSelectionPrompt = {
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
projects: parsedProjects,
|
||||||
|
defaultProjectId,
|
||||||
|
supportsAll: output.includes('ALL'),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Request selection from UI (with timeout fallback to default)
|
||||||
|
const selectedId = await requestProjectSelection(prompt);
|
||||||
|
|
||||||
|
// Write selection to stdin (empty = default, else project ID or ALL)
|
||||||
|
const response = selectedId || '';
|
||||||
|
log(`User selected: ${response || '(default)'}`);
|
||||||
|
authProcess.stdin?.write(response + '\n');
|
||||||
|
} catch {
|
||||||
|
// Fallback to default on error
|
||||||
|
log('Project selection failed, using default');
|
||||||
|
authProcess.stdin?.write('\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// CLI mode or no projects: auto-select default
|
||||||
|
log('CLI mode or no projects, auto-selecting default');
|
||||||
|
authProcess.stdin?.write('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect when callback server starts or browser opens
|
// Detect when callback server starts or browser opens
|
||||||
if (!browserOpened && (output.includes('listening') || output.includes('http'))) {
|
if (!browserOpened && (output.includes('listening') || output.includes('http'))) {
|
||||||
process.stdout.write('\x1b[1A\x1b[2K');
|
process.stdout.write('\x1b[1A\x1b[2K');
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Project Selection Handler
|
||||||
|
*
|
||||||
|
* Manages interactive project selection prompts during OAuth flow.
|
||||||
|
* Parses CLIProxyAPI stdout for project lists and coordinates
|
||||||
|
* between backend (stdin writer) and frontend (WebSocket/UI).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed project from CLIProxyAPI output
|
||||||
|
*/
|
||||||
|
export interface GCloudProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project selection prompt data sent to UI
|
||||||
|
*/
|
||||||
|
export interface ProjectSelectionPrompt {
|
||||||
|
sessionId: string;
|
||||||
|
provider: string;
|
||||||
|
projects: GCloudProject[];
|
||||||
|
defaultProjectId: string;
|
||||||
|
supportsAll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project selection response from UI
|
||||||
|
*/
|
||||||
|
export interface ProjectSelectionResponse {
|
||||||
|
sessionId: string;
|
||||||
|
selectedId: string; // Project ID or 'ALL'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pending project selection with resolver
|
||||||
|
*/
|
||||||
|
interface PendingSelection {
|
||||||
|
prompt: ProjectSelectionPrompt;
|
||||||
|
resolve: (value: string) => void;
|
||||||
|
timeout: NodeJS.Timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global event emitter for project selection events
|
||||||
|
export const projectSelectionEvents = new EventEmitter();
|
||||||
|
|
||||||
|
// Pending selections by session ID
|
||||||
|
const pendingSelections = new Map<string, PendingSelection>();
|
||||||
|
|
||||||
|
// Default timeout for project selection (30 seconds)
|
||||||
|
const SELECTION_TIMEOUT_MS = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse project list from CLIProxyAPI stdout
|
||||||
|
*
|
||||||
|
* Expected format:
|
||||||
|
* "Available Google Cloud projects:
|
||||||
|
* [1] project-id-123 (Project Name)
|
||||||
|
* [2] another-project (Another Name)
|
||||||
|
* Type 'ALL' to onboard every listed project."
|
||||||
|
*/
|
||||||
|
export function parseProjectList(output: string): GCloudProject[] {
|
||||||
|
const projects: GCloudProject[] = [];
|
||||||
|
|
||||||
|
// Match lines like: [1] project-id (Project Name)
|
||||||
|
const projectRegex = /\[(\d+)\]\s+(\S+)\s+\(([^)]+)\)/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = projectRegex.exec(output)) !== null) {
|
||||||
|
projects.push({
|
||||||
|
index: parseInt(match[1], 10),
|
||||||
|
id: match[2],
|
||||||
|
name: match[3],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse default project ID from prompt
|
||||||
|
*
|
||||||
|
* Expected format: "Enter project ID [default-project-id] or ALL:"
|
||||||
|
*/
|
||||||
|
export function parseDefaultProject(output: string): string | null {
|
||||||
|
const match = output.match(/Enter project ID \[([^\]]+)\] or ALL:/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if output contains project selection prompt
|
||||||
|
*/
|
||||||
|
export function isProjectSelectionPrompt(output: string): boolean {
|
||||||
|
return output.includes('Enter project ID') && output.includes('or ALL:');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if output contains project list
|
||||||
|
*/
|
||||||
|
export function isProjectList(output: string): boolean {
|
||||||
|
return output.includes('Available Google Cloud projects:');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate unique session ID for OAuth flow
|
||||||
|
*/
|
||||||
|
export function generateSessionId(): string {
|
||||||
|
return `auth-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request project selection from UI
|
||||||
|
* Returns promise that resolves when user selects a project
|
||||||
|
*
|
||||||
|
* @param prompt - Project selection prompt data
|
||||||
|
* @returns Promise<string> - Selected project ID or 'ALL', or default if timeout
|
||||||
|
*/
|
||||||
|
export function requestProjectSelection(prompt: ProjectSelectionPrompt): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Set timeout for auto-selection
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
const pending = pendingSelections.get(prompt.sessionId);
|
||||||
|
if (pending) {
|
||||||
|
pendingSelections.delete(prompt.sessionId);
|
||||||
|
// Auto-select default on timeout
|
||||||
|
resolve(prompt.defaultProjectId);
|
||||||
|
projectSelectionEvents.emit('selection:timeout', prompt.sessionId);
|
||||||
|
}
|
||||||
|
}, SELECTION_TIMEOUT_MS);
|
||||||
|
|
||||||
|
// Store pending selection
|
||||||
|
pendingSelections.set(prompt.sessionId, {
|
||||||
|
prompt,
|
||||||
|
resolve,
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit event for WebSocket broadcast
|
||||||
|
projectSelectionEvents.emit('selection:required', prompt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit project selection from UI
|
||||||
|
*
|
||||||
|
* @param response - Selection response with session ID and selected project
|
||||||
|
* @returns boolean - True if selection was accepted
|
||||||
|
*/
|
||||||
|
export function submitProjectSelection(response: ProjectSelectionResponse): boolean {
|
||||||
|
const pending = pendingSelections.get(response.sessionId);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear timeout and remove from pending
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pendingSelections.delete(response.sessionId);
|
||||||
|
|
||||||
|
// Resolve the promise with selected ID
|
||||||
|
pending.resolve(response.selectedId);
|
||||||
|
projectSelectionEvents.emit('selection:submitted', response);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel pending project selection
|
||||||
|
*/
|
||||||
|
export function cancelProjectSelection(sessionId: string): boolean {
|
||||||
|
const pending = pendingSelections.get(sessionId);
|
||||||
|
|
||||||
|
if (!pending) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(pending.timeout);
|
||||||
|
pendingSelections.delete(sessionId);
|
||||||
|
|
||||||
|
// Resolve with default (empty string to auto-select)
|
||||||
|
pending.resolve('');
|
||||||
|
projectSelectionEvents.emit('selection:cancelled', sessionId);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pending project selection prompt
|
||||||
|
*/
|
||||||
|
export function getPendingSelection(sessionId: string): ProjectSelectionPrompt | null {
|
||||||
|
const pending = pendingSelections.get(sessionId);
|
||||||
|
return pending ? pending.prompt : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there's a pending selection
|
||||||
|
*/
|
||||||
|
export function hasPendingSelection(sessionId: string): boolean {
|
||||||
|
return pendingSelections.has(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
parseProjectList,
|
||||||
|
parseDefaultProject,
|
||||||
|
isProjectSelectionPrompt,
|
||||||
|
isProjectList,
|
||||||
|
generateSessionId,
|
||||||
|
requestProjectSelection,
|
||||||
|
submitProjectSelection,
|
||||||
|
cancelProjectSelection,
|
||||||
|
getPendingSelection,
|
||||||
|
hasPendingSelection,
|
||||||
|
projectSelectionEvents,
|
||||||
|
};
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
initializeAccounts,
|
initializeAccounts,
|
||||||
triggerOAuth,
|
triggerOAuth,
|
||||||
} from '../cliproxy/auth-handler';
|
} from '../cliproxy/auth-handler';
|
||||||
|
import { submitProjectSelection, getPendingSelection } from '../cliproxy/project-selection-handler';
|
||||||
import {
|
import {
|
||||||
fetchCliproxyStats,
|
fetchCliproxyStats,
|
||||||
fetchCliproxyModels,
|
fetchCliproxyModels,
|
||||||
@@ -639,6 +640,7 @@ apiRoutes.post(
|
|||||||
add: true, // Always add mode from UI
|
add: true, // Always add mode from UI
|
||||||
headless: false, // Force interactive mode
|
headless: false, // Force interactive mode
|
||||||
nickname: nickname || undefined,
|
nickname: nickname || undefined,
|
||||||
|
fromUI: true, // Enable project selection prompt in UI
|
||||||
});
|
});
|
||||||
|
|
||||||
if (account) {
|
if (account) {
|
||||||
@@ -661,6 +663,48 @@ apiRoutes.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/cliproxy/auth/project-selection/:sessionId - Get pending project selection prompt
|
||||||
|
* Returns project list for user to select from during OAuth flow
|
||||||
|
*/
|
||||||
|
apiRoutes.get(
|
||||||
|
'/cliproxy/auth/project-selection/:sessionId',
|
||||||
|
(req: Request, res: Response): void => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
|
||||||
|
const pending = getPendingSelection(sessionId);
|
||||||
|
if (pending) {
|
||||||
|
res.json(pending);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'No pending project selection for this session' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/cliproxy/auth/project-selection/:sessionId - Submit project selection
|
||||||
|
* Submits user's project choice during OAuth flow
|
||||||
|
*/
|
||||||
|
apiRoutes.post(
|
||||||
|
'/cliproxy/auth/project-selection/:sessionId',
|
||||||
|
(req: Request, res: Response): void => {
|
||||||
|
const { sessionId } = req.params;
|
||||||
|
const { selectedId } = req.body;
|
||||||
|
|
||||||
|
if (!selectedId && selectedId !== '') {
|
||||||
|
res.status(400).json({ error: 'selectedId is required (use empty string for default)' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = submitProjectSelection({ sessionId, selectedId });
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'No pending project selection for this session' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ==================== Settings (Phase 05) ====================
|
// ==================== Settings (Phase 05) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
* WebSocket Handler (Phase 04)
|
* WebSocket Handler (Phase 04)
|
||||||
*
|
*
|
||||||
* Manages WebSocket connections, broadcasts file changes, and handles client messages.
|
* Manages WebSocket connections, broadcasts file changes, and handles client messages.
|
||||||
|
* Also broadcasts project selection events during OAuth flows.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { WebSocketServer, WebSocket } from 'ws';
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
import { createFileWatcher, FileChangeEvent } from './file-watcher';
|
import { createFileWatcher, FileChangeEvent } from './file-watcher';
|
||||||
import { info, warn } from '../utils/ui';
|
import { info, warn } from '../utils/ui';
|
||||||
|
import {
|
||||||
|
projectSelectionEvents,
|
||||||
|
type ProjectSelectionPrompt,
|
||||||
|
} from '../cliproxy/project-selection-handler';
|
||||||
|
|
||||||
export interface WSMessage {
|
export interface WSMessage {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -81,9 +86,51 @@ export function setupWebSocket(wss: WebSocketServer): { cleanup: () => void } {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Listen for project selection events and broadcast to clients
|
||||||
|
const handleProjectSelectionRequired = (prompt: ProjectSelectionPrompt): void => {
|
||||||
|
console.log(info(`[WS] Broadcasting project selection prompt (session: ${prompt.sessionId})`));
|
||||||
|
broadcast({
|
||||||
|
type: 'projectSelectionRequired',
|
||||||
|
...prompt,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectSelectionTimeout = (sessionId: string): void => {
|
||||||
|
console.log(info(`[WS] Project selection timed out (session: ${sessionId})`));
|
||||||
|
broadcast({
|
||||||
|
type: 'projectSelectionTimeout',
|
||||||
|
sessionId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectSelectionSubmitted = (response: {
|
||||||
|
sessionId: string;
|
||||||
|
selectedId: string;
|
||||||
|
}): void => {
|
||||||
|
console.log(info(`[WS] Project selection submitted (session: ${response.sessionId})`));
|
||||||
|
broadcast({
|
||||||
|
type: 'projectSelectionSubmitted',
|
||||||
|
...response,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subscribe to project selection events
|
||||||
|
projectSelectionEvents.on('selection:required', handleProjectSelectionRequired);
|
||||||
|
projectSelectionEvents.on('selection:timeout', handleProjectSelectionTimeout);
|
||||||
|
projectSelectionEvents.on('selection:submitted', handleProjectSelectionSubmitted);
|
||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
const cleanup = (): void => {
|
const cleanup = (): void => {
|
||||||
watcher.close();
|
watcher.close();
|
||||||
|
|
||||||
|
// Unsubscribe from project selection events
|
||||||
|
projectSelectionEvents.off('selection:required', handleProjectSelectionRequired);
|
||||||
|
projectSelectionEvents.off('selection:timeout', handleProjectSelectionTimeout);
|
||||||
|
projectSelectionEvents.off('selection:submitted', handleProjectSelectionSubmitted);
|
||||||
|
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
client.close(1001, 'Server shutting down');
|
client.close(1001, 'Server shutting down');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ClaudeKitBadge } from '@/components/claudekit-badge';
|
import { ClaudeKitBadge } from '@/components/claudekit-badge';
|
||||||
import { SponsorButton } from '@/components/sponsor-button';
|
import { SponsorButton } from '@/components/sponsor-button';
|
||||||
|
import { ProjectSelectionDialog } from '@/components/project-selection-dialog';
|
||||||
|
import { useProjectSelection } from '@/hooks/use-project-selection';
|
||||||
|
|
||||||
function PageLoader() {
|
function PageLoader() {
|
||||||
return (
|
return (
|
||||||
@@ -22,6 +24,8 @@ function PageLoader() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout() {
|
export function Layout() {
|
||||||
|
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
@@ -46,6 +50,20 @@ export function Layout() {
|
|||||||
</div>
|
</div>
|
||||||
<LocalhostDisclaimer />
|
<LocalhostDisclaimer />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Global project selection dialog for OAuth flows */}
|
||||||
|
{prompt && (
|
||||||
|
<ProjectSelectionDialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
sessionId={prompt.sessionId}
|
||||||
|
provider={prompt.provider}
|
||||||
|
projects={prompt.projects}
|
||||||
|
defaultProjectId={prompt.defaultProjectId}
|
||||||
|
supportsAll={prompt.supportsAll}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* Project Selection Dialog Component
|
||||||
|
*
|
||||||
|
* Displays during OAuth flow when CLIProxyAPI requires user to select
|
||||||
|
* a Google Cloud project. Shows list of available projects with option
|
||||||
|
* to select one or ALL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2, FolderOpen, Check, Circle, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface GCloudProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSelectionDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
sessionId: string;
|
||||||
|
provider: string;
|
||||||
|
projects: GCloudProject[];
|
||||||
|
defaultProjectId: string;
|
||||||
|
supportsAll: boolean;
|
||||||
|
onSelect: (selectedId: string) => Promise<void>;
|
||||||
|
/** Timeout in seconds before auto-selecting default */
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSelectionDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
sessionId,
|
||||||
|
provider,
|
||||||
|
projects,
|
||||||
|
defaultProjectId,
|
||||||
|
supportsAll,
|
||||||
|
onSelect,
|
||||||
|
timeoutSeconds = 30,
|
||||||
|
}: ProjectSelectionDialogProps) {
|
||||||
|
const [selectedId, setSelectedId] = useState(defaultProjectId);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(timeoutSeconds);
|
||||||
|
|
||||||
|
// Countdown timer for auto-selection
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setCountdown(timeoutSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
// Auto-submit on timeout
|
||||||
|
handleSubmit(true);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, timeoutSeconds]);
|
||||||
|
|
||||||
|
// Reset state when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedId(defaultProjectId);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setCountdown(timeoutSeconds);
|
||||||
|
}
|
||||||
|
}, [open, defaultProjectId, timeoutSeconds]);
|
||||||
|
|
||||||
|
const handleSubmit = async (isTimeout = false) => {
|
||||||
|
if (isSubmitting) return;
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Empty string means use default (press Enter behavior)
|
||||||
|
const submitId = isTimeout ? '' : selectedId;
|
||||||
|
await onSelect(submitId);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to submit project selection:', error);
|
||||||
|
// On error, submit empty to use default
|
||||||
|
try {
|
||||||
|
await onSelect('');
|
||||||
|
} catch {
|
||||||
|
// Ignore double-error
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const providerDisplay = provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||||
|
|
||||||
|
// Suppress unused variable warning - sessionId used for identification
|
||||||
|
void sessionId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && !isSubmitting && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<FolderOpen className="w-5 h-5" />
|
||||||
|
Select Google Cloud Project
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose which project to use for {providerDisplay} authentication.
|
||||||
|
{countdown > 0 && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
(Auto-selecting default in {countdown}s)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
selectedId === project.id
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => !isSubmitting && setSelectedId(project.id)}
|
||||||
|
>
|
||||||
|
{selectedId === project.id ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{project.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground font-mono">{project.id}</div>
|
||||||
|
</div>
|
||||||
|
{project.id === defaultProjectId && (
|
||||||
|
<span className="text-xs px-2 py-1 bg-secondary rounded">Default</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{supportsAll && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||||
|
selectedId === 'ALL'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => !isSubmitting && setSelectedId('ALL')}
|
||||||
|
>
|
||||||
|
{selectedId === 'ALL' ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-primary" />
|
||||||
|
) : (
|
||||||
|
<Circle className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">All Projects</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Onboard all {projects.length} listed projects
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-2">
|
||||||
|
<Button variant="ghost" onClick={() => handleSubmit(true)} disabled={isSubmitting}>
|
||||||
|
Use Default
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => handleSubmit(false)} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Selecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
Confirm Selection
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Project Selection Hook
|
||||||
|
*
|
||||||
|
* Listens for WebSocket project selection events and manages the dialog state.
|
||||||
|
* Used in conjunction with ProjectSelectionDialog component.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface GCloudProject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSelectionPrompt {
|
||||||
|
sessionId: string;
|
||||||
|
provider: string;
|
||||||
|
projects: GCloudProject[];
|
||||||
|
defaultProjectId: string;
|
||||||
|
supportsAll: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSelectionState {
|
||||||
|
isOpen: boolean;
|
||||||
|
prompt: ProjectSelectionPrompt | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectSelection() {
|
||||||
|
const [state, setState] = useState<ProjectSelectionState>({
|
||||||
|
isOpen: false,
|
||||||
|
prompt: 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 === 'projectSelectionRequired') {
|
||||||
|
console.log('[ProjectSelection] Received prompt:', data.sessionId);
|
||||||
|
setState({
|
||||||
|
isOpen: true,
|
||||||
|
prompt: {
|
||||||
|
sessionId: data.sessionId as string,
|
||||||
|
provider: data.provider as string,
|
||||||
|
projects: data.projects as GCloudProject[],
|
||||||
|
defaultProjectId: data.defaultProjectId as string,
|
||||||
|
supportsAll: data.supportsAll as boolean,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (data.type === 'projectSelectionTimeout') {
|
||||||
|
console.log('[ProjectSelection] Timeout:', data.sessionId);
|
||||||
|
// Close dialog if this session timed out
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.prompt?.sessionId === data.sessionId) {
|
||||||
|
return { isOpen: false, prompt: null };
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
} else if (data.type === 'projectSelectionSubmitted') {
|
||||||
|
console.log('[ProjectSelection] Submitted:', data.sessionId);
|
||||||
|
// Close dialog if this session was submitted
|
||||||
|
setState((prev) => {
|
||||||
|
if (prev.prompt?.sessionId === data.sessionId) {
|
||||||
|
return { isOpen: false, prompt: null };
|
||||||
|
}
|
||||||
|
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 handleSelect = useCallback(
|
||||||
|
async (selectedId: string) => {
|
||||||
|
if (!state.prompt) return;
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/cliproxy/auth/project-selection/${state.prompt.sessionId}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ selectedId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to submit project selection');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[state.prompt]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setState({ isOpen: false, prompt: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: state.isOpen,
|
||||||
|
prompt: state.prompt,
|
||||||
|
onSelect: handleSelect,
|
||||||
|
onClose: handleClose,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -82,6 +82,9 @@ export function useWebSocket() {
|
|||||||
try {
|
try {
|
||||||
const message: WSMessage = JSON.parse(event.data);
|
const message: WSMessage = JSON.parse(event.data);
|
||||||
handleMessage(message);
|
handleMessage(message);
|
||||||
|
|
||||||
|
// Dispatch custom event for other hooks to listen (e.g., project selection)
|
||||||
|
window.dispatchEvent(new CustomEvent('ws-message', { detail: message }));
|
||||||
} catch {
|
} catch {
|
||||||
console.log('[WS] Invalid message');
|
console.log('[WS] Invalid message');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user