mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
feat: 集成 ChatGPT OAuth 订阅登录到 /login UI
添加 Codex ChatGPT 菜单项、OAuth 等待界面、手动 code 输入支持。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { useKeybinding } from '../keybindings/useKeybinding.js'
|
|||||||
import { getSSLErrorHint } from '@ant/model-provider'
|
import { getSSLErrorHint } from '@ant/model-provider'
|
||||||
import { sendNotification } from '../services/notifier.js'
|
import { sendNotification } from '../services/notifier.js'
|
||||||
import { OAuthService } from '../services/oauth/index.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 { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'
|
||||||
import { logError } from '../utils/log.js'
|
import { logError } from '../utils/log.js'
|
||||||
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'
|
import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'
|
||||||
@@ -55,6 +56,8 @@ type OAuthStatus =
|
|||||||
opusModel: string
|
opusModel: string
|
||||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'
|
||||||
} // Gemini Generate Content API platform
|
} // 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: 'ready_to_start' } // Flow started, waiting for browser to open
|
||||||
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
| { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login
|
||||||
| { state: 'creating_api_key' } // Got access token, creating API key
|
| { state: 'creating_api_key' } // Got access token, creating API key
|
||||||
@@ -108,6 +111,13 @@ export function ConsoleOAuthFlow({
|
|||||||
const [showPastePrompt, setShowPastePrompt] = useState(false)
|
const [showPastePrompt, setShowPastePrompt] = useState(false)
|
||||||
const [urlCopied, setUrlCopied] = 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
|
const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1
|
||||||
|
|
||||||
// Log forced login method on mount
|
// Log forced login method on mount
|
||||||
@@ -186,6 +196,39 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [pastedCode, oauthStatus, showPastePrompt, urlCopied])
|
}, [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) {
|
async function handleSubmitCode(value: string, url: string) {
|
||||||
try {
|
try {
|
||||||
// Expecting format "authorizationCode#state" from the authorization callback URL
|
// Expecting format "authorizationCode#state" from the authorization callback URL
|
||||||
@@ -301,6 +344,64 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID])
|
}, [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)
|
const pendingOAuthStartRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -316,6 +417,19 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [oauthStatus.state, startOAuth])
|
}, [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
|
// Auto-exit for setup-token mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode === 'setup-token' && oauthStatus.state === 'success') {
|
if (mode === 'setup-token' && oauthStatus.state === 'success') {
|
||||||
@@ -334,6 +448,20 @@ export function ConsoleOAuthFlow({
|
|||||||
}
|
}
|
||||||
}, [mode, oauthStatus, loginWithClaudeAi, onDone])
|
}, [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
|
// Cleanup OAuth service when component unmounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -399,6 +527,13 @@ export function ConsoleOAuthFlow({
|
|||||||
setOAuthStatus={setOAuthStatus}
|
setOAuthStatus={setOAuthStatus}
|
||||||
setLoginWithClaudeAi={setLoginWithClaudeAi}
|
setLoginWithClaudeAi={setLoginWithClaudeAi}
|
||||||
onDone={onDone}
|
onDone={onDone}
|
||||||
|
showCodexPastePrompt={showCodexPastePrompt}
|
||||||
|
codexUrlCopied={codexUrlCopied}
|
||||||
|
codexPastedCode={codexPastedCode}
|
||||||
|
setCodexPastedCode={setCodexPastedCode}
|
||||||
|
codexPastedCursor={codexPastedCursor}
|
||||||
|
setCodexPastedCursor={setCodexPastedCursor}
|
||||||
|
handleCodexPasteSubmit={handleCodexPasteSubmit}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -420,6 +555,14 @@ type OAuthStatusMessageProps = {
|
|||||||
handleSubmitCode: (value: string, url: string) => void
|
handleSubmitCode: (value: string, url: string) => void
|
||||||
setOAuthStatus: (status: OAuthStatus) => void
|
setOAuthStatus: (status: OAuthStatus) => void
|
||||||
setLoginWithClaudeAi: (value: boolean) => 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({
|
function OAuthStatusMessage({
|
||||||
@@ -437,6 +580,13 @@ function OAuthStatusMessage({
|
|||||||
setOAuthStatus,
|
setOAuthStatus,
|
||||||
setLoginWithClaudeAi,
|
setLoginWithClaudeAi,
|
||||||
onDone,
|
onDone,
|
||||||
|
showCodexPastePrompt,
|
||||||
|
codexUrlCopied,
|
||||||
|
codexPastedCode,
|
||||||
|
setCodexPastedCode,
|
||||||
|
codexPastedCursor,
|
||||||
|
setCodexPastedCursor,
|
||||||
|
handleCodexPasteSubmit,
|
||||||
}: OAuthStatusMessageProps): React.ReactNode {
|
}: OAuthStatusMessageProps): React.ReactNode {
|
||||||
switch (oauthStatus.state) {
|
switch (oauthStatus.state) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
@@ -475,6 +625,16 @@ function OAuthStatusMessage({
|
|||||||
),
|
),
|
||||||
value: 'openai_chat_api',
|
value: 'openai_chat_api',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<Text>
|
||||||
|
OpenAI Codex (ChatGPT Subscription) -{' '}
|
||||||
|
<Text dimColor>Login with ChatGPT Plus/Pro</Text>
|
||||||
|
{'\n'}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
value: 'codex_chatgpt',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: (
|
label: (
|
||||||
<Text>
|
<Text>
|
||||||
@@ -552,6 +712,9 @@ function OAuthStatusMessage({
|
|||||||
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
|
opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '',
|
||||||
activeField: 'base_url',
|
activeField: 'base_url',
|
||||||
})
|
})
|
||||||
|
} else if (value === 'codex_chatgpt') {
|
||||||
|
logEvent('tengu_codex_chatgpt_selected', {})
|
||||||
|
setOAuthStatus({ state: 'codex_oauth_start' })
|
||||||
} else if (value === 'gemini_api') {
|
} else if (value === 'gemini_api') {
|
||||||
logEvent('tengu_gemini_api_selected', {})
|
logEvent('tengu_gemini_api_selected', {})
|
||||||
setOAuthStatus({
|
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'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':
|
case 'platform_setup':
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" gap={1} marginTop={1}>
|
<Box flexDirection="column" gap={1} marginTop={1}>
|
||||||
|
|||||||
Reference in New Issue
Block a user