import React, { useCallback, useEffect, useRef, useState } from 'react' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { installOAuthTokens } from '../cli/handlers/auth.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcutHint } from '@anthropic/ink' import { useKeybinding } from '../keybindings/useKeybinding.js' import { getSSLErrorHint } from '@ant/model-provider' import { sendNotification } from '../services/notifier.js' import { OAuthService } from '../services/oauth/index.js' import { performOpenAICodexLogin, parseManualCodeInput } from '../services/oauth/openai-codex.js' import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js' import { logError } from '../utils/log.js' import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js' import { Select } from './CustomSelect/select.js' import { Spinner } from './Spinner.js' import TextInput from './TextInput.js' import { fi } from 'zod/v4/locales' type Props = { onDone(): void startingMessage?: string mode?: 'login' | 'setup-token' forceLoginMethod?: 'claudeai' | 'console' } type OAuthStatus = | { state: 'idle' } // Initial state, waiting to select login method | { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry) | { state: 'custom_platform' baseUrl: string apiKey: string haikuModel: string sonnetModel: string opusModel: string activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' } // Custom platform: configure API endpoint and model names | { state: 'openai_chat_api' baseUrl: string apiKey: string haikuModel: string sonnetModel: string opusModel: string activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' } // OpenAI Chat Completions API platform | { state: 'gemini_api' baseUrl: string apiKey: string haikuModel: string sonnetModel: string opusModel: string activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' } // Gemini Generate Content API platform | { state: 'codex_oauth_waiting'; url: string } // ChatGPT OAuth browser login in progress | { state: 'codex_oauth_start' } // Trigger ChatGPT OAuth flow | { state: 'codex_models' haikuModel: string sonnetModel: string opusModel: string activeField: 'haiku_model' | 'sonnet_model' | 'opus_model' codexResult: { apiKey: string | null accessToken: string refreshToken: string } } // Codex model name configuration after OAuth success | { state: 'ready_to_start' } // Flow started, waiting for browser to open | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login | { state: 'creating_api_key' } // Got access token, creating API key | { state: 'about_to_retry'; nextState: OAuthStatus } | { state: 'success'; token?: string } | { state: 'error' message: string toRetry?: OAuthStatus } const PASTE_HERE_MSG = 'Paste code here if prompted > ' export function ConsoleOAuthFlow({ onDone, startingMessage, mode = 'login', forceLoginMethod: forceLoginMethodProp, }: Props): React.ReactNode { const settings = getSettings_DEPRECATED() || {} const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod const orgUUID = settings.forceLoginOrgUUID const forcedMethodMessage = forceLoginMethod === 'claudeai' ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' : forceLoginMethod === 'console' ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' : null const terminal = useTerminalNotification() const [oauthStatus, setOAuthStatus] = useState(() => { if (mode === 'setup-token') { return { state: 'ready_to_start' } } if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { return { state: 'ready_to_start' } } return { state: 'idle' } }) const [pastedCode, setPastedCode] = useState('') const [cursorOffset, setCursorOffset] = useState(0) const [oauthService] = useState(() => new OAuthService()) const [loginWithClaudeAi, setLoginWithClaudeAi] = useState(() => { // Use Claude AI auth for setup-token mode to support user:inference scope return mode === 'setup-token' || forceLoginMethod === 'claudeai' }) // After a few seconds we suggest the user to copy/paste url if the // browser did not open automatically. In this flow we expect the user to // copy the code from the browser and paste it in the terminal const [showPastePrompt, setShowPastePrompt] = useState(false) const [urlCopied, setUrlCopied] = useState(false) // Codex ChatGPT OAuth states const [showCodexPastePrompt, setShowCodexPastePrompt] = useState(false) const [codexUrlCopied, setCodexUrlCopied] = useState(false) const [codexPastedCode, setCodexPastedCode] = useState('') const [codexPastedCursor, setCodexPastedCursor] = useState(0) const codexManualCodeResolveRef = useRef<((code: string) => void) | null>(null) const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1 // Log forced login method on mount useEffect(() => { if (forceLoginMethod === 'claudeai') { logEvent('tengu_oauth_claudeai_forced', {}) } else if (forceLoginMethod === 'console') { logEvent('tengu_oauth_console_forced', {}) } }, [forceLoginMethod]) // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState) return () => clearTimeout(timer) } }, [oauthStatus]) // Handle Enter to continue on success state useKeybinding( 'confirm:yes', () => { logEvent('tengu_oauth_success', { loginWithClaudeAi }) onDone() }, { context: 'Confirmation', isActive: oauthStatus.state === 'success' && mode !== 'setup-token', }, ) // Handle Enter to continue from platform setup useKeybinding( 'confirm:yes', () => { setOAuthStatus({ state: 'idle' }) }, { context: 'Confirmation', isActive: oauthStatus.state === 'platform_setup', }, ) // Handle Enter to retry on error state useKeybinding( 'confirm:yes', () => { if (oauthStatus.state === 'error' && oauthStatus.toRetry) { setPastedCode('') setOAuthStatus({ state: 'about_to_retry', nextState: oauthStatus.toRetry, }) } }, { context: 'Confirmation', isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry, }, ) useEffect(() => { if ( pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied ) { void setClipboard(oauthStatus.url).then(raw => { if (raw) process.stdout.write(raw) setUrlCopied(true) setTimeout(setUrlCopied, 2000, false) }) setPastedCode('') } }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]) // Codex OAuth: copy URL on 'c' useEffect(() => { if ( codexPastedCode === 'c' && oauthStatus.state === 'codex_oauth_waiting' && showCodexPastePrompt && !codexUrlCopied ) { const url = (oauthStatus as { state: 'codex_oauth_waiting'; url: string }).url void setClipboard(url).then(raw => { if (raw) process.stdout.write(raw) setCodexUrlCopied(true) setTimeout(setCodexUrlCopied, 2000, false) }) setCodexPastedCode('') } }, [codexPastedCode, oauthStatus, showCodexPastePrompt, codexUrlCopied]) // Codex OAuth: submit pasted code const handleCodexPasteSubmit = useCallback((value: string) => { const code = parseManualCodeInput(value) if (!code) { setOAuthStatus({ state: 'error', message: 'Invalid code. Paste the full redirect URL or just the authorization code.', toRetry: oauthStatus as any, }) return } codexManualCodeResolveRef.current?.(code) codexManualCodeResolveRef.current = null }, [oauthStatus]) async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL const [authorizationCode, state] = value.split('#') if (!authorizationCode || !state) { setOAuthStatus({ state: 'error', message: 'Invalid code. Please make sure the full code was copied', toRetry: { state: 'waiting_for_login', url }, }) return } // Track which path the user is taking (manual code entry) logEvent('tengu_oauth_manual_entry', {}) oauthService.handleManualAuthCodeInput({ authorizationCode, state, }) } catch (err: unknown) { logError(err) setOAuthStatus({ state: 'error', message: (err as Error).message, toRetry: { state: 'waiting_for_login', url }, }) } } const startOAuth = useCallback(async () => { try { logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }) const result = await oauthService .startOAuthFlow( async url => { setOAuthStatus({ state: 'waiting_for_login', url }) setTimeout(setShowPastePrompt, 3000, true) }, { loginWithClaudeAi, inferenceOnly: mode === 'setup-token', expiresIn: mode === 'setup-token' ? 365 * 24 * 60 * 60 : undefined, // 1 year for setup-token orgUUID, }, ) .catch(err => { const isTokenExchangeError = err.message.includes( 'Token exchange failed', ) // Enterprise TLS proxies (Zscaler et al.) intercept the token // exchange POST and cause cryptic SSL errors. Surface an // actionable hint so the user isn't stuck in a login loop. const sslHint = getSSLErrorHint(err) setOAuthStatus({ state: 'error', message: sslHint ?? (isTokenExchangeError ? 'Failed to exchange authorization code for access token. Please try again.' : err.message), toRetry: mode === 'setup-token' ? { state: 'ready_to_start' } : { state: 'idle' }, }) logEvent('tengu_oauth_token_exchange_error', { error: err.message, ssl_error: sslHint !== null, }) throw err }) if (mode === 'setup-token') { // For setup-token mode, return the OAuth access token directly (it can be used as an API key) // Don't save to keychain - the token is displayed for manual use with CLAUDE_CODE_OAUTH_TOKEN setOAuthStatus({ state: 'success', token: result.accessToken }) } else { await installOAuthTokens(result) const orgResult = await validateForceLoginOrg() if (!orgResult.valid) { throw new Error((orgResult as { valid: false; message: string }).message) } // Reset modelType to anthropic when using OAuth login updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any) setOAuthStatus({ state: 'success' }) void sendNotification( { message: 'Claude Code login successful', notificationType: 'auth_success', }, terminal, ) } } catch (err) { const errorMessage = (err as Error).message const sslHint = getSSLErrorHint(err) setOAuthStatus({ state: 'error', message: sslHint ?? errorMessage, toRetry: { state: mode === 'setup-token' ? 'ready_to_start' : 'idle', }, }) logEvent('tengu_oauth_error', { error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ssl_error: sslHint !== null, }) } }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]) const startCodexOAuth = useCallback(async () => { setShowCodexPastePrompt(false) setCodexUrlCopied(false) setCodexPastedCode('') setCodexPastedCursor(0) let manualCodeResolve: ((code: string) => void) | null = null const manualCodePromise = new Promise(resolve => { manualCodeResolve = resolve }) codexManualCodeResolveRef.current = manualCodeResolve try { const result = await performOpenAICodexLogin({ onUrl: url => { setOAuthStatus({ state: 'codex_oauth_waiting', url }) setTimeout(setShowCodexPastePrompt, 3000, true) }, manualCode: manualCodePromise, }) // Transition to model configuration panel with defaults setOAuthStatus({ state: 'codex_models', haikuModel: process.env.CODEX_DEFAULT_HAIKU_MODEL || 'gpt-5.4-mini', sonnetModel: process.env.CODEX_DEFAULT_SONNET_MODEL || 'gpt-5.4-mini', opusModel: process.env.CODEX_DEFAULT_OPUS_MODEL || 'gpt-5.5', activeField: 'haiku_model', codexResult: { apiKey: result.apiKey, accessToken: result.accessToken, refreshToken: result.refreshToken, }, }) } catch (err) { logError(err as Error) setOAuthStatus({ state: 'error', message: (err as Error).message, toRetry: { state: 'idle' }, }) } finally { codexManualCodeResolveRef.current = null } }, [onDone]) const pendingOAuthStartRef = useRef(false) useEffect(() => { if ( oauthStatus.state === 'ready_to_start' && !pendingOAuthStartRef.current ) { pendingOAuthStartRef.current = true // Start OAuth flow and reset the pending flag when complete void startOAuth().finally(() => { pendingOAuthStartRef.current = false }) } }, [oauthStatus.state, startOAuth]) const pendingCodexOAuthRef = useRef(false) useEffect(() => { if ( oauthStatus.state === 'codex_oauth_start' && !pendingCodexOAuthRef.current ) { pendingCodexOAuthRef.current = true void startCodexOAuth().finally(() => { pendingCodexOAuthRef.current = false }) } }, [oauthStatus.state, startCodexOAuth]) // Auto-exit for setup-token mode useEffect(() => { if (mode === 'setup-token' && oauthStatus.state === 'success') { // Delay to ensure static content is fully rendered before exiting const timer = setTimeout( (loginWithClaudeAi, onDone) => { logEvent('tengu_oauth_success', { loginWithClaudeAi }) // Don't clear terminal so the token remains visible onDone() }, 500, loginWithClaudeAi, onDone, ) return () => clearTimeout(timer) } }, [mode, oauthStatus, loginWithClaudeAi, onDone]) // Cancel codex OAuth with Escape useKeybinding( 'confirm:no', () => { setShowCodexPastePrompt(false) setCodexPastedCode('') setOAuthStatus({ state: 'idle' }) }, { context: 'Confirmation', isActive: oauthStatus.state === 'codex_oauth_waiting', }, ) // Cleanup OAuth service when component unmounts useEffect(() => { return () => { oauthService.cleanup() } }, [oauthService]) return ( {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( Browser didn't open? Use the url below to sign in{' '} {urlCopied ? ( (Copied!) ) : ( )} {oauthStatus.url} )} {mode === 'setup-token' && oauthStatus.state === 'success' && oauthStatus.token && ( ✓ Long-lived authentication token created successfully! Your OAuth token (valid for 1 year): {oauthStatus.token} Store this token securely. You won't be able to see it again. Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token> )} ) } type OAuthStatusMessageProps = { oauthStatus: OAuthStatus mode: 'login' | 'setup-token' startingMessage: string | undefined forcedMethodMessage: string | null showPastePrompt: boolean pastedCode: string setPastedCode: (value: string) => void cursorOffset: number onDone: () => void setCursorOffset: (offset: number) => void textInputColumns: number handleSubmitCode: (value: string, url: string) => void setOAuthStatus: (status: OAuthStatus) => void setLoginWithClaudeAi: (value: boolean) => void // Codex ChatGPT OAuth props showCodexPastePrompt: boolean codexUrlCopied: boolean codexPastedCode: string setCodexPastedCode: (value: string) => void codexPastedCursor: number setCodexPastedCursor: (offset: number) => void handleCodexPasteSubmit: (value: string) => void } function OAuthStatusMessage({ oauthStatus, mode, startingMessage, forcedMethodMessage, showPastePrompt, pastedCode, setPastedCode, cursorOffset, setCursorOffset, textInputColumns, handleSubmitCode, setOAuthStatus, setLoginWithClaudeAi, onDone, showCodexPastePrompt, codexUrlCopied, codexPastedCode, setCodexPastedCode, codexPastedCursor, setCodexPastedCursor, handleCodexPasteSubmit, }: OAuthStatusMessageProps): React.ReactNode { switch (oauthStatus.state) { case 'idle': return ( {startingMessage ? startingMessage : `Claude Code can be used with your Claude subscription or billed based on API usage through your Console account.`} Select login method: