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 { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js' import { Spinner } from '../../components/Spinner.js' import TextInput from '../../components/TextInput.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' import { setClipboard } from '../../ink/termio/osc.js' import { Box, Link, Text } from '../../ink.js' import { OAuthService } from '../../services/oauth/index.js' import { saveOAuthTokensIfNeeded } from '../../utils/auth.js' import { logError } from '../../utils/log.js' interface OAuthFlowStepProps { onSuccess: (token: string) => void onCancel: () => void } type OAuthStatus = | { state: 'starting' } | { state: 'waiting_for_login'; url: string } | { state: 'processing' } | { state: 'success'; token: string } | { state: 'error'; message: string; toRetry?: OAuthStatus } | { state: 'about_to_retry'; nextState: OAuthStatus } const PASTE_HERE_MSG = 'Paste code here if prompted > ' export function OAuthFlowStep({ onSuccess, onCancel, }: OAuthFlowStepProps): React.ReactNode { const [oauthStatus, setOAuthStatus] = useState({ state: 'starting', }) const [oauthService] = useState(() => new OAuthService()) const [pastedCode, setPastedCode] = useState('') const [cursorOffset, setCursorOffset] = useState(0) const [showPastePrompt, setShowPastePrompt] = useState(false) const [urlCopied, setUrlCopied] = useState(false) const timersRef = useRef>(new Set()) // Separate ref so startOAuth's timer clear doesn't cancel the urlCopied reset const urlCopiedTimerRef = useRef(undefined) const terminalSize = useTerminalSize() const textInputColumns = Math.max( 50, terminalSize.columns - PASTE_HERE_MSG.length - 4, ) function handleKeyDown(e: KeyboardEvent): void { if (oauthStatus.state !== 'error') return e.preventDefault() if (e.key === 'return' && oauthStatus.toRetry) { setPastedCode('') setCursorOffset(0) setOAuthStatus({ state: 'about_to_retry', nextState: oauthStatus.toRetry, }) } else { onCancel() } } 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 () => { // Clear any existing timers when starting new OAuth flow timersRef.current.forEach(timer => clearTimeout(timer)) timersRef.current.clear() try { const result = await oauthService.startOAuthFlow( async url => { setOAuthStatus({ state: 'waiting_for_login', url }) const timer = setTimeout(setShowPastePrompt, 3000, true) timersRef.current.add(timer) }, { loginWithClaudeAi: true, // Always use Claude AI for subscription tokens inferenceOnly: true, expiresIn: 365 * 24 * 60 * 60, // 1 year }, ) // Show processing state setOAuthStatus({ state: 'processing' }) // OAuthFlowStep creates inference-only tokens for GitHub Actions, not a // replacement login. Use saveOAuthTokensIfNeeded directly to avoid // performLogout which would destroy the user's existing auth session. saveOAuthTokensIfNeeded(result) // For OAuth flow, the access token can be used as an API key const timer1 = setTimeout( (setOAuthStatus, accessToken, onSuccess, timersRef) => { setOAuthStatus({ state: 'success', token: accessToken }) // Auto-continue after brief delay to show success const timer2 = setTimeout(onSuccess, 1000, accessToken) timersRef.current.add(timer2) }, 100, setOAuthStatus, result.accessToken, onSuccess, timersRef, ) timersRef.current.add(timer1) } catch (err) { const errorMessage = (err as Error).message setOAuthStatus({ state: 'error', message: errorMessage, toRetry: { state: 'starting' }, // Allow retry by starting fresh OAuth flow }) logError(err) logEvent('tengu_oauth_error', { error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } }, [oauthService, onSuccess]) useEffect(() => { if (oauthStatus.state === 'starting') { void startOAuth() } }, [oauthStatus.state, startOAuth]) // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { const timer = setTimeout( (nextState, setShowPastePrompt, setOAuthStatus) => { // Only show paste prompt when retrying to waiting_for_login setShowPastePrompt(nextState.state === 'waiting_for_login') setOAuthStatus(nextState) }, 500, oauthStatus.nextState, setShowPastePrompt, setOAuthStatus, ) timersRef.current.add(timer) } }, [oauthStatus]) 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) clearTimeout(urlCopiedTimerRef.current) urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false) }) setPastedCode('') } }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]) // Cleanup OAuth service and timers when component unmounts useEffect(() => { const timers = timersRef.current return () => { oauthService.cleanup() // Clear all timers timers.forEach(timer => clearTimeout(timer)) timers.clear() clearTimeout(urlCopiedTimerRef.current) } }, [oauthService]) // Helper function to render the appropriate status message function renderStatusMessage(): React.ReactNode { switch (oauthStatus.state) { case 'starting': return ( Starting authentication… ) case 'waiting_for_login': return ( {!showPastePrompt && ( Opening browser to sign in with your Claude account… )} {showPastePrompt && ( {PASTE_HERE_MSG} handleSubmitCode(value, oauthStatus.url) } cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} /> )} ) case 'processing': return ( Processing authentication… ) case 'success': return ( ✓ Authentication token created successfully! Using token for GitHub Actions setup… ) case 'error': return ( OAuth error: {oauthStatus.message} {oauthStatus.toRetry ? ( Press Enter to try again, or any other key to cancel ) : ( Press any key to return to API key selection )} ) case 'about_to_retry': return ( Retrying… ) default: return null } } return ( {/* Show header inline only for initial starting state */} {oauthStatus.state === 'starting' && ( Create Authentication Token Creating a long-lived token for GitHub Actions )} {/* Show header for non-starting states (to avoid duplicate with inline header)*/} {oauthStatus.state !== 'success' && oauthStatus.state !== 'starting' && oauthStatus.state !== 'processing' && ( Create Authentication Token Creating a long-lived token for GitHub Actions )} {/* Show URL when paste prompt is visible */} {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( Browser didn't open? Use the url below to sign in{' '} {urlCopied ? ( (Copied!) ) : ( )} {oauthStatus.url} )} {renderStatusMessage()} ) }