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:
kaitranntt committed 2025-12-18 04:11:59 -05:00
1 parent a87ef973fc
commit a66abba174
8 files changed
+714 -17

No files matched your search

+18
View File
@@ -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>
);
}
+111
View File
@@ -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,
};
}
+3
View File
@@ -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');
}