mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-20 05:11:48 +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
@@ -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