feat: 集成 ChatGPT OAuth 订阅登录到 /login UI

添加 Codex ChatGPT 菜单项、OAuth 等待界面、手动 code 输入支持。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-26 21:58:30 +08:00
parent bc4a2f1281
commit cd59a88d44

View File

@@ -10,6 +10,7 @@ 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'
@@ -55,6 +56,8 @@ type OAuthStatus =
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: '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
@@ -108,6 +111,13 @@ export function ConsoleOAuthFlow({
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
@@ -186,6 +196,39 @@ export function ConsoleOAuthFlow({
}
}, [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
@@ -301,6 +344,64 @@ export function ConsoleOAuthFlow({
}
}, [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<string>(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,
})
const env: Record<string, string | undefined> = {
CODEX_API_KEY: result.apiKey ?? undefined,
CODEX_ACCESS_TOKEN: result.accessToken,
CODEX_REFRESH_TOKEN: result.refreshToken,
CODEX_LOGIN_METHOD: 'chatgpt_subscription',
}
updateSettingsForSource('userSettings', {
modelType: 'openai-responses' as any,
env,
} as any)
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
process.env[key] = value
}
}
setOAuthStatus({ state: 'success' })
void sendNotification(
{
message: 'OpenAI Codex (ChatGPT) login successful',
notificationType: 'auth_success',
},
terminal,
)
onDone()
} 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(() => {
@@ -316,6 +417,19 @@ export function ConsoleOAuthFlow({
}
}, [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') {
@@ -334,6 +448,20 @@ export function ConsoleOAuthFlow({
}
}, [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 () => {
@@ -399,6 +527,13 @@ export function ConsoleOAuthFlow({
setOAuthStatus={setOAuthStatus}
setLoginWithClaudeAi={setLoginWithClaudeAi}
onDone={onDone}
showCodexPastePrompt={showCodexPastePrompt}
codexUrlCopied={codexUrlCopied}
codexPastedCode={codexPastedCode}
setCodexPastedCode={setCodexPastedCode}
codexPastedCursor={codexPastedCursor}
setCodexPastedCursor={setCodexPastedCursor}
handleCodexPasteSubmit={handleCodexPasteSubmit}
/>
</Box>
</Box>
@@ -420,6 +555,14 @@ type OAuthStatusMessageProps = {
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({
@@ -437,6 +580,13 @@ function OAuthStatusMessage({
setOAuthStatus,
setLoginWithClaudeAi,
onDone,
showCodexPastePrompt,
codexUrlCopied,
codexPastedCode,
setCodexPastedCode,
codexPastedCursor,
setCodexPastedCursor,
handleCodexPasteSubmit,
}: OAuthStatusMessageProps): React.ReactNode {
switch (oauthStatus.state) {
case 'idle':
@@ -475,6 +625,16 @@ function OAuthStatusMessage({
),
value: 'openai_chat_api',
},
{
label: (
<Text>
OpenAI Codex (ChatGPT Subscription) -{' '}
<Text dimColor>Login with ChatGPT Plus/Pro</Text>
{'\n'}
</Text>
),
value: 'codex_chatgpt',
},
{
label: (
<Text>
@@ -552,6 +712,9 @@ function OAuthStatusMessage({
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
activeField: 'base_url',
})
} else if (value === 'codex_chatgpt') {
logEvent('tengu_codex_chatgpt_selected', {})
setOAuthStatus({ state: 'codex_oauth_start' })
} else if (value === 'gemini_api') {
logEvent('tengu_gemini_api_selected', {})
setOAuthStatus({
@@ -1275,6 +1438,57 @@ function OAuthStatusMessage({
)
}
case 'codex_oauth_waiting': {
const { url } = oauthStatus as { state: 'codex_oauth_waiting'; url: string }
const codexPasteColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
return (
<Box flexDirection="column" gap={1}>
{!showCodexPastePrompt && (
<Box>
<Spinner />
<Text>Opening browser for ChatGPT login...</Text>
</Box>
)}
{showCodexPastePrompt && (
<Box flexDirection="column" gap={1}>
<Box paddingX={1}>
<Text dimColor>
Browser didn&apos;t open? Use the url below to sign in{' '}
</Text>
{codexUrlCopied ? (
<Text color="success">(Copied!)</Text>
) : (
<Text dimColor>
<KeyboardShortcutHint shortcut="c" action="copy" parens />
</Text>
)}
</Box>
<Link url={url}>
<Text dimColor>{url}</Text>
</Link>
</Box>
)}
{showCodexPastePrompt && (
<Box>
<Text>{PASTE_HERE_MSG}</Text>
<TextInput
value={codexPastedCode}
onChange={setCodexPastedCode}
onSubmit={handleCodexPasteSubmit}
cursorOffset={codexPastedCursor}
onChangeCursorOffset={setCodexPastedCursor}
columns={codexPasteColumns}
mask="*"
/>
</Box>
)}
<Text dimColor>
Press <Text bold>Esc</Text> to cancel
</Text>
</Box>
)
}
case 'platform_setup':
return (
<Box flexDirection="column" gap={1} marginTop={1}>