mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +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:
@@ -32,6 +32,16 @@ import {
|
||||
enhancedPreflightOAuthCheck,
|
||||
OAUTH_CALLBACK_PORTS as OAUTH_PORTS,
|
||||
} 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)
|
||||
@@ -451,20 +461,8 @@ function getTimeoutTroubleshooting(provider: CLIProxyProvider, port: number | nu
|
||||
lines.push(' 1. Check browser completed auth (should show success page)');
|
||||
|
||||
if (port) {
|
||||
if (process.platform === 'win32') {
|
||||
lines.push(` 2. Verify port ${port} is reachable: curl http://localhost:${port}`);
|
||||
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`);
|
||||
}
|
||||
lines.push(` 2. Check for port conflicts: lsof -ti:${port} or ss -tlnp | grep ${port}`);
|
||||
lines.push(` 3. Try: ccs ${provider} --auth --verbose`);
|
||||
} else {
|
||||
lines.push(` 2. Try: ccs ${provider} --auth --verbose`);
|
||||
}
|
||||
@@ -489,11 +487,14 @@ export async function triggerOAuth(
|
||||
account?: string;
|
||||
add?: boolean;
|
||||
nickname?: string;
|
||||
/** If true, triggered from Web UI (enables project selection prompt) */
|
||||
fromUI?: boolean;
|
||||
} = {}
|
||||
): Promise<AccountInfo | null> {
|
||||
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 isCLI = !fromUI; // CLI mode = auto-select default project
|
||||
|
||||
// Auto-detect headless if not explicitly set
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
@@ -620,8 +621,9 @@ export async function triggerOAuth(
|
||||
|
||||
return new Promise<AccountInfo | null>((resolve) => {
|
||||
// Spawn CLIProxyAPI with auth flag
|
||||
// Use pipe for stdin to auto-respond to interactive prompts (e.g., project selection)
|
||||
const authProcess = spawn(binaryPath, args, {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
CLI_PROXY_AUTH_DIR: tokenDir,
|
||||
@@ -631,12 +633,63 @@ export async function triggerOAuth(
|
||||
let stderrData = '';
|
||||
let urlDisplayed = 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();
|
||||
|
||||
authProcess.stdout?.on('data', (data: Buffer) => {
|
||||
authProcess.stdout?.on('data', async (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
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
|
||||
if (!browserOpened && (output.includes('listening') || output.includes('http'))) {
|
||||
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,
|
||||
triggerOAuth,
|
||||
} from '../cliproxy/auth-handler';
|
||||
import { submitProjectSelection, getPendingSelection } from '../cliproxy/project-selection-handler';
|
||||
import {
|
||||
fetchCliproxyStats,
|
||||
fetchCliproxyModels,
|
||||
@@ -639,6 +640,7 @@ apiRoutes.post(
|
||||
add: true, // Always add mode from UI
|
||||
headless: false, // Force interactive mode
|
||||
nickname: nickname || undefined,
|
||||
fromUI: true, // Enable project selection prompt in UI
|
||||
});
|
||||
|
||||
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) ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
* WebSocket Handler (Phase 04)
|
||||
*
|
||||
* Manages WebSocket connections, broadcasts file changes, and handles client messages.
|
||||
* Also broadcasts project selection events during OAuth flows.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { createFileWatcher, FileChangeEvent } from './file-watcher';
|
||||
import { info, warn } from '../utils/ui';
|
||||
import {
|
||||
projectSelectionEvents,
|
||||
type ProjectSelectionPrompt,
|
||||
} from '../cliproxy/project-selection-handler';
|
||||
|
||||
export interface WSMessage {
|
||||
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
|
||||
const cleanup = (): void => {
|
||||
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) => {
|
||||
client.close(1001, 'Server shutting down');
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import { LocalhostDisclaimer } from '@/components/localhost-disclaimer';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ClaudeKitBadge } from '@/components/claudekit-badge';
|
||||
import { SponsorButton } from '@/components/sponsor-button';
|
||||
import { ProjectSelectionDialog } from '@/components/project-selection-dialog';
|
||||
import { useProjectSelection } from '@/hooks/use-project-selection';
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -22,6 +24,8 @@ function PageLoader() {
|
||||
}
|
||||
|
||||
export function Layout() {
|
||||
const { isOpen, prompt, onSelect, onClose } = useProjectSelection();
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
@@ -46,6 +50,20 @@ export function Layout() {
|
||||
</div>
|
||||
<LocalhostDisclaimer />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
const message: WSMessage = JSON.parse(event.data);
|
||||
handleMessage(message);
|
||||
|
||||
// Dispatch custom event for other hooks to listen (e.g., project selection)
|
||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: message }));
|
||||
} catch {
|
||||
console.log('[WS] Invalid message');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user