diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index abae2ba8..eeb6df55 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -18,11 +18,25 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Loader2, ExternalLink, User, Download, Copy, Check } from 'lucide-react'; import { useKiroImport } from '@/hooks/use-cliproxy'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { applyDefaultPreset } from '@/lib/preset-utils'; -import { isDeviceCodeProvider, isNicknameRequiredProvider } from '@/lib/provider-config'; +import { + DEFAULT_KIRO_AUTH_METHOD, + getKiroAuthMethodOption, + isDeviceCodeProvider, + isNicknameRequiredProvider, + KIRO_AUTH_METHOD_OPTIONS, + KiroAuthMethod, +} from '@/lib/provider-config'; import { toast } from 'sonner'; interface AddAccountDialogProps { @@ -45,13 +59,16 @@ export function AddAccountDialog({ const [callbackUrl, setCallbackUrl] = useState(''); const [copied, setCopied] = useState(false); const [localError, setLocalError] = useState(null); + const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); const wasAuthenticatingRef = useRef(false); const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; - const isDeviceCode = isDeviceCodeProvider(provider); + const defaultDeviceCode = isDeviceCodeProvider(provider); const requiresNickname = isNicknameRequiredProvider(provider); + const kiroMethodOption = getKiroAuthMethodOption(kiroAuthMethod); + const isDeviceCode = isKiro ? kiroMethodOption.flowType === 'device_code' : defaultDeviceCode; const isPending = authFlow.isAuthenticating || kiroImportMutation.isPending; const nicknameTrimmed = nickname.trim(); const errorMessage = localError || authFlow.error; @@ -61,6 +78,7 @@ export function AddAccountDialog({ setCallbackUrl(''); setCopied(false); setLocalError(null); + setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); wasAuthenticatingRef.current = false; onClose(); }; @@ -119,7 +137,12 @@ export function AddAccountDialog({ } setLocalError(null); wasAuthenticatingRef.current = true; - authFlow.startAuth(provider, { nickname: nicknameTrimmed || undefined }); + authFlow.startAuth(provider, { + nickname: nicknameTrimmed || undefined, + kiroMethod: isKiro ? kiroAuthMethod : undefined, + flowType: isKiro ? kiroMethodOption.flowType : undefined, + startEndpoint: isKiro ? kiroMethodOption.startEndpoint : undefined, + }); }; const handleKiroImport = () => { @@ -156,7 +179,7 @@ export function AddAccountDialog({ Add {displayName} Account {isKiro - ? 'Authenticate via browser or import an existing token from Kiro IDE.' + ? 'Choose a Kiro auth method, then authenticate via browser or import from Kiro IDE.' : isDeviceCode ? 'Click Authenticate. A verification code will appear for you to enter on the provider website.' : 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.'} @@ -164,6 +187,32 @@ export function AddAccountDialog({
+ {/* Kiro auth method */} + {isKiro && !showAuthUI && ( +
+ + +

{kiroMethodOption.description}

+
+ )} + {/* Nickname input - only show before auth starts */} {!showAuthUI && (
@@ -282,6 +331,12 @@ export function AddAccountDialog({
)} + + {!authFlow.authUrl && !authFlow.isDeviceCodeFlow && ( +

+ Preparing sign-in URL... +

+ )} )} diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 73a1d5c4..a1f8f954 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -25,6 +25,9 @@ interface AuthFlowState { interface StartAuthOptions { nickname?: string; + kiroMethod?: string; + flowType?: 'authorization_code' | 'device_code'; + startEndpoint?: 'start' | 'start-url'; } /** Polling interval for OAuth status check (3 seconds) */ @@ -49,6 +52,7 @@ export function useCliproxyAuthFlow() { const abortControllerRef = useRef(null); const pollIntervalRef = useRef | null>(null); const pollStartRef = useRef(0); + const openedAuthUrlRef = useRef(false); const queryClient = useQueryClient(); // Clear polling @@ -64,6 +68,7 @@ export function useCliproxyAuthFlow() { return () => { abortControllerRef.current?.abort(); stopPolling(); + openedAuthUrlRef.current = false; }; }, [stopPolling]); @@ -85,14 +90,46 @@ export function useCliproxyAuthFlow() { const response = await fetch( `/api/cliproxy/auth/${provider}/status?state=${encodeURIComponent(oauthState)}` ); - const data = await response.json(); + const data = (await response.json()) as { + status?: string; + error?: string; + url?: string; + auth_url?: string; + verification_url?: string; + user_code?: string; + }; if (data.status === 'ok') { stopPolling(); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); toast.success(`${provider} authentication successful`); + openedAuthUrlRef.current = false; setState(INITIAL_STATE); + } else if (data.status === 'auth_url') { + const authUrl = data.url || data.auth_url; + if (authUrl) { + setState((prev) => ({ + ...prev, + authUrl, + })); + if (!openedAuthUrlRef.current) { + openedAuthUrlRef.current = true; + window.open(authUrl, '_blank'); + } + } + } else if (data.status === 'device_code') { + stopPolling(); + const details = + data.user_code && data.verification_url + ? `Open ${data.verification_url} and enter code: ${data.user_code}` + : 'Switch to Device Code method and try again.'; + toast.error('Provider returned Device Code flow in callback mode'); + setState((prev) => ({ + ...prev, + isAuthenticating: false, + error: details, + })); } else if (data.status === 'error') { stopPolling(); const errorMsg = data.error || 'Authentication failed'; @@ -103,7 +140,7 @@ export function useCliproxyAuthFlow() { error: errorMsg, })); } - // status === 'pending' means continue polling + // status === 'wait' (or pending) means continue polling } catch { // Network error - continue polling } @@ -124,12 +161,21 @@ export function useCliproxyAuthFlow() { // Abort any in-progress auth abortControllerRef.current?.abort(); stopPolling(); + openedAuthUrlRef.current = false; // Create fresh controller and capture locally to avoid race with cancelAuth const controller = new AbortController(); abortControllerRef.current = controller; - const deviceCodeFlow = isDeviceCodeProvider(provider); + const flowType = + options?.flowType || + (isDeviceCodeProvider(provider) ? 'device_code' : 'authorization_code'); + const deviceCodeFlow = flowType === 'device_code'; + const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url'); + const payload = { + nickname: options?.nickname, + kiroMethod: options?.kiroMethod, + }; setState({ provider, @@ -142,14 +188,13 @@ export function useCliproxyAuthFlow() { }); try { - if (deviceCodeFlow) { - // Device Code Flow: Call /start endpoint which spawns CLIProxyAPI binary. - // This emits WebSocket events with userCode that DeviceCodeDialog will display. - // The /start endpoint blocks until completion, so we don't await it here. + if (startEndpoint === 'start') { + // /start spawns CLIProxy binary and blocks until completion. + // For Device Code flows, userCode is delivered via WebSocket. fetch(`/api/cliproxy/auth/${provider}/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nickname: options?.nickname }), + body: JSON.stringify(payload), signal: controller.signal, }) .then(async (response) => { @@ -159,6 +204,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['account-quota'] }); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast // via deviceCodeCompleted WebSocket event to avoid duplicate toasts + openedAuthUrlRef.current = false; setState(INITIAL_STATE); } else { const errorMsg = data.error || 'Authentication failed'; @@ -183,13 +229,13 @@ export function useCliproxyAuthFlow() { error: message, })); }); - // Don't await - let the request run in background while DeviceCodeDialog handles UI + // Don't await - keeps UI responsive while backend auth is in progress } else { - // Authorization Code Flow: Call /start-url to get auth URL immediately (non-blocking) + // /start-url uses management API to bootstrap callback/social flows. const response = await fetch(`/api/cliproxy/auth/${provider}/start-url`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ nickname: options?.nickname }), + body: JSON.stringify(payload), signal: controller.signal, }); @@ -202,12 +248,13 @@ export function useCliproxyAuthFlow() { // Update state with auth URL setState((prev) => ({ ...prev, - authUrl: data.authUrl, + authUrl: data.authUrl || null, oauthState: data.state, })); // Auto-open auth URL in new browser tab (fallback URL still shown in dialog) if (data.authUrl) { + openedAuthUrlRef.current = true; window.open(data.authUrl, '_blank'); } @@ -221,6 +268,7 @@ export function useCliproxyAuthFlow() { } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { + openedAuthUrlRef.current = false; setState(INITIAL_STATE); return; } @@ -240,6 +288,7 @@ export function useCliproxyAuthFlow() { const currentProvider = state.provider; abortControllerRef.current?.abort(); stopPolling(); + openedAuthUrlRef.current = false; setState(INITIAL_STATE); // Also cancel on backend if (currentProvider) { diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 2da393ba..05e2a958 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -90,3 +90,67 @@ export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro']; export function isNicknameRequiredProvider(provider: string): boolean { return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider); } + +/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */ +export const KIRO_AUTH_METHODS = ['aws', 'aws-authcode', 'google', 'github'] as const; +export type KiroAuthMethod = (typeof KIRO_AUTH_METHODS)[number]; + +export type KiroFlowType = 'authorization_code' | 'device_code'; +export type KiroStartEndpoint = 'start' | 'start-url'; + +export interface KiroAuthMethodOption { + id: KiroAuthMethod; + label: string; + description: string; + flowType: KiroFlowType; + startEndpoint: KiroStartEndpoint; +} + +/** UX-first default for issue #233: AWS Builder ID device flow. */ +export const DEFAULT_KIRO_AUTH_METHOD: KiroAuthMethod = 'aws'; + +export const KIRO_AUTH_METHOD_OPTIONS: readonly KiroAuthMethodOption[] = [ + { + id: 'aws', + label: 'AWS Builder ID (Recommended)', + description: 'Device code flow for AWS organizations and Builder ID accounts.', + flowType: 'device_code', + startEndpoint: 'start', + }, + { + id: 'aws-authcode', + label: 'AWS Builder ID (Auth Code)', + description: 'Authorization code flow via CLI binary.', + flowType: 'authorization_code', + startEndpoint: 'start', + }, + { + id: 'google', + label: 'Google OAuth', + description: 'Social OAuth flow with callback URL support.', + flowType: 'authorization_code', + startEndpoint: 'start-url', + }, + { + id: 'github', + label: 'GitHub OAuth', + description: 'Social OAuth flow via management API callback.', + flowType: 'authorization_code', + startEndpoint: 'start-url', + }, +]; + +export function isKiroAuthMethod(value: string): value is KiroAuthMethod { + return KIRO_AUTH_METHODS.includes(value as KiroAuthMethod); +} + +export function normalizeKiroAuthMethod(value?: string): KiroAuthMethod { + if (!value) return DEFAULT_KIRO_AUTH_METHOD; + const normalized = value.trim().toLowerCase(); + return isKiroAuthMethod(normalized) ? normalized : DEFAULT_KIRO_AUTH_METHOD; +} + +export function getKiroAuthMethodOption(method: KiroAuthMethod): KiroAuthMethodOption { + const option = KIRO_AUTH_METHOD_OPTIONS.find((candidate) => candidate.id === method); + return option || KIRO_AUTH_METHOD_OPTIONS[0]; +}