From 39d86d133a38d8ed852238798733dee58f472218 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 19 Dec 2025 18:04:14 +0800 Subject: [PATCH] feat(oauth): add callback URL submission and require Gemini CLI project ID --- src/i18n/locales/en.json | 17 ++- src/i18n/locales/zh-CN.json | 17 ++- src/pages/OAuthPage.module.scss | 44 ++++++++ src/pages/OAuthPage.tsx | 188 +++++++++++++++++++++++--------- src/services/api/oauth.ts | 31 +++++- 5 files changed, 236 insertions(+), 61 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 02bde96..5af177c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -424,9 +424,10 @@ "gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_button": "Start Gemini CLI Login", "gemini_cli_oauth_hint": "Login to Google Gemini CLI service through OAuth flow, automatically obtain and save authentication files.", - "gemini_cli_project_id_label": "Google Cloud Project ID (Optional):", - "gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID (optional)", - "gemini_cli_project_id_hint": "If a project ID is specified, authentication information for that project will be used.", + "gemini_cli_project_id_label": "Google Cloud Project ID:", + "gemini_cli_project_id_placeholder": "Enter Google Cloud Project ID", + "gemini_cli_project_id_hint": "Project ID is required for Gemini CLI OAuth.", + "gemini_cli_project_id_required": "Please enter a Google Cloud project ID.", "gemini_cli_oauth_url_label": "Authorization URL:", "gemini_cli_open_link": "Open Link", "gemini_cli_copy_link": "Copy Link", @@ -446,6 +447,16 @@ "qwen_oauth_status_error": "Authentication failed:", "qwen_oauth_start_error": "Failed to start Qwen OAuth:", "qwen_oauth_polling_error": "Failed to check authentication status:", + "oauth_callback_label": "Callback URL", + "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", + "oauth_callback_hint": "Remote browser mode: after the provider redirects to http://localhost:..., copy the full URL and submit it here.", + "oauth_callback_button": "Submit Callback URL", + "oauth_callback_required": "Please paste the full redirect URL first.", + "oauth_callback_success": "Callback URL submitted. Continue waiting for authentication.", + "oauth_callback_error": "Failed to submit callback URL:", + "oauth_callback_upgrade_hint": "Please update CLI Proxy API or check the connection.", + "oauth_callback_status_success": "Callback URL submitted, waiting for authentication...", + "oauth_callback_status_error": "Callback URL submission failed:", "missing_state": "Unable to retrieve authentication state parameter", "iflow_oauth_title": "iFlow OAuth", "iflow_oauth_button": "Start iFlow Login", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4309577..624fbb8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -424,9 +424,10 @@ "gemini_cli_oauth_title": "Gemini CLI OAuth", "gemini_cli_oauth_button": "开始 Gemini CLI 登录", "gemini_cli_oauth_hint": "通过 OAuth 流程登录 Google Gemini CLI 服务,自动获取并保存认证文件。", - "gemini_cli_project_id_label": "Google Cloud 项目 ID (可选):", - "gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID (可选)", - "gemini_cli_project_id_hint": "如果指定了项目 ID,将使用该项目的认证信息。", + "gemini_cli_project_id_label": "Google Cloud 项目 ID:", + "gemini_cli_project_id_placeholder": "输入 Google Cloud 项目 ID", + "gemini_cli_project_id_hint": "请填写项目 ID,用于 Gemini CLI OAuth 登录。", + "gemini_cli_project_id_required": "请填写 Google Cloud 项目 ID。", "gemini_cli_oauth_url_label": "授权链接:", "gemini_cli_open_link": "打开链接", "gemini_cli_copy_link": "复制链接", @@ -446,6 +447,16 @@ "qwen_oauth_status_error": "认证失败:", "qwen_oauth_start_error": "启动 Qwen OAuth 失败:", "qwen_oauth_polling_error": "检查认证状态失败:", + "oauth_callback_label": "回调 URL", + "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", + "oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。", + "oauth_callback_button": "提交回调 URL", + "oauth_callback_required": "请先粘贴完整的回调 URL。", + "oauth_callback_success": "回调 URL 已提交,请继续等待认证。", + "oauth_callback_error": "提交回调 URL 失败:", + "oauth_callback_upgrade_hint": "请更新CLI Proxy API或检查连接", + "oauth_callback_status_success": "回调 URL 已提交,等待认证中...", + "oauth_callback_status_error": "回调 URL 提交失败:", "missing_state": "无法获取认证状态参数", "iflow_oauth_title": "iFlow OAuth", "iflow_oauth_button": "开始 iFlow 登录", diff --git a/src/pages/OAuthPage.module.scss b/src/pages/OAuthPage.module.scss index 0bc426f..5173c0f 100644 --- a/src/pages/OAuthPage.module.scss +++ b/src/pages/OAuthPage.module.scss @@ -59,3 +59,47 @@ color: #3b82f6; } } + +.callbackSection { + margin-top: $spacing-md; + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.callbackActions { + display: flex; + gap: $spacing-md; +} + +.authUrlBox { + background: var(--bg-secondary); + border: 1px dashed var(--border-color); + border-radius: $radius-md; + padding: $spacing-md; + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.authUrlLabel { + color: var(--text-secondary); + font-size: 14px; +} + +.authUrlValue { + font-weight: 700; + color: var(--text-primary); + word-break: break-all; + overflow-wrap: anywhere; + line-height: 1.5; + max-width: 100%; +} + +.authUrlActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $spacing-sm; + margin-top: $spacing-sm; +} diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index 92abf57..23d9c53 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -1,11 +1,10 @@ -import { useEffect, useRef, useState, useMemo } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { useNotificationStore } from '@/stores'; import { oauthApi, type OAuthProvider, type IFlowCookieAuthResponse } from '@/services/api/oauth'; -import { isLocalhost } from '@/utils/connection'; import styles from './OAuthPage.module.scss'; interface ProviderState { @@ -14,6 +13,12 @@ interface ProviderState { status?: 'idle' | 'waiting' | 'success' | 'error'; error?: string; polling?: boolean; + projectId?: string; + projectIdError?: string; + callbackUrl?: string; + callbackSubmitting?: boolean; + callbackStatus?: 'success' | 'error'; + callbackError?: string; } interface IFlowCookieState { @@ -33,6 +38,8 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe { id: 'iflow', titleKey: 'auth_login.iflow_oauth_title', hintKey: 'auth_login.iflow_oauth_hint', urlLabelKey: 'auth_login.iflow_oauth_url_label' } ]; +const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow']; + export function OAuthPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); @@ -40,15 +47,19 @@ export function OAuthPage() { const [iflowCookie, setIflowCookie] = useState({ cookie: '', loading: false }); const timers = useRef>({}); - // 检测是否为本地访问 - const isLocal = useMemo(() => isLocalhost(window.location.hostname), []); - useEffect(() => { return () => { Object.values(timers.current).forEach((timer) => window.clearInterval(timer)); }; }, []); + const updateProviderState = (provider: OAuthProvider, next: Partial) => { + setStates((prev) => ({ + ...prev, + [provider]: { ...(prev[provider] ?? {}), ...next } + })); + }; + const startPolling = (provider: OAuthProvider, state: string) => { if (timers.current[provider]) { clearInterval(timers.current[provider]); @@ -57,27 +68,18 @@ export function OAuthPage() { try { const res = await oauthApi.getAuthStatus(state); if (res.status === 'ok') { - setStates((prev) => ({ - ...prev, - [provider]: { ...prev[provider], status: 'success', polling: false } - })); + updateProviderState(provider, { status: 'success', polling: false }); showNotification(t('auth_login.codex_oauth_status_success'), 'success'); window.clearInterval(timer); delete timers.current[provider]; } else if (res.status === 'error') { - setStates((prev) => ({ - ...prev, - [provider]: { ...prev[provider], status: 'error', error: res.error, polling: false } - })); + updateProviderState(provider, { status: 'error', error: res.error, polling: false }); showNotification(`${t('auth_login.codex_oauth_status_error')} ${res.error || ''}`, 'error'); window.clearInterval(timer); delete timers.current[provider]; } } catch (err: any) { - setStates((prev) => ({ - ...prev, - [provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false } - })); + updateProviderState(provider, { status: 'error', error: err?.message, polling: false }); window.clearInterval(timer); delete timers.current[provider]; } @@ -86,24 +88,35 @@ export function OAuthPage() { }; const startAuth = async (provider: OAuthProvider) => { - setStates((prev) => ({ - ...prev, - [provider]: { ...prev[provider], status: 'waiting', polling: true, error: undefined } - })); + const projectId = provider === 'gemini-cli' ? (states[provider]?.projectId || '').trim() : undefined; + if (provider === 'gemini-cli' && !projectId) { + const message = t('auth_login.gemini_cli_project_id_required'); + updateProviderState(provider, { projectIdError: message }); + showNotification(message, 'warning'); + return; + } + if (provider === 'gemini-cli') { + updateProviderState(provider, { projectIdError: undefined }); + } + updateProviderState(provider, { + status: 'waiting', + polling: true, + error: undefined, + callbackStatus: undefined, + callbackError: undefined, + callbackUrl: '' + }); try { - const res = await oauthApi.startAuth(provider); - setStates((prev) => ({ - ...prev, - [provider]: { ...prev[provider], url: res.url, state: res.state, status: 'waiting', polling: true } - })); + const res = await oauthApi.startAuth( + provider, + provider === 'gemini-cli' ? { projectId: projectId! } : undefined + ); + updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true }); if (res.state) { startPolling(provider, res.state); } } catch (err: any) { - setStates((prev) => ({ - ...prev, - [provider]: { ...prev[provider], status: 'error', error: err?.message, polling: false } - })); + updateProviderState(provider, { status: 'error', error: err?.message, polling: false }); showNotification(`${t('auth_login.codex_oauth_start_error')} ${err?.message || ''}`, 'error'); } }; @@ -118,6 +131,40 @@ export function OAuthPage() { } }; + const submitCallback = async (provider: OAuthProvider) => { + const redirectUrl = (states[provider]?.callbackUrl || '').trim(); + if (!redirectUrl) { + showNotification(t('auth_login.oauth_callback_required'), 'warning'); + return; + } + updateProviderState(provider, { + callbackSubmitting: true, + callbackStatus: undefined, + callbackError: undefined + }); + try { + await oauthApi.submitCallback(provider, redirectUrl); + updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'success' }); + showNotification(t('auth_login.oauth_callback_success'), 'success'); + } catch (err: any) { + const errorMessage = + err?.status === 404 + ? t('auth_login.oauth_callback_upgrade_hint', { + defaultValue: 'Please update CLI Proxy API or check the connection.' + }) + : err?.message; + updateProviderState(provider, { + callbackSubmitting: false, + callbackStatus: 'error', + callbackError: errorMessage + }); + const notificationMessage = errorMessage + ? `${t('auth_login.oauth_callback_error')} ${errorMessage}` + : t('auth_login.oauth_callback_error'); + showNotification(notificationMessage, 'error'); + } + }; + const submitIflowCookie = async () => { const cookie = iflowCookie.cookie.trim(); if (!cookie) { @@ -164,36 +211,38 @@ export function OAuthPage() {
{PROVIDERS.map((provider) => { const state = states[provider.id] || {}; - // 非本地访问时禁用所有 OAuth 登录方式 - const isDisabled = !isLocal; + const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url); return ( -
+
startAuth(provider.id)} - loading={state.polling} - disabled={isDisabled} - > + } >
{t(provider.hintKey)}
- {isDisabled && ( -
- {t('auth_login.remote_access_disabled')} -
+ {provider.id === 'gemini-cli' && ( + + updateProviderState(provider.id, { + projectId: e.target.value, + projectIdError: undefined + }) + } + placeholder={t('auth_login.gemini_cli_project_id_placeholder')} + /> )} - {!isDisabled && state.url && ( -
-
{t(provider.urlLabelKey)}
-
{state.url}
-
+ {state.url && ( +
+
{t(provider.urlLabelKey)}
+
{state.url}
+
@@ -207,7 +256,44 @@ export function OAuthPage() {
)} - {!isDisabled && state.status && state.status !== 'idle' && ( + {canSubmitCallback && ( +
+ + updateProviderState(provider.id, { + callbackUrl: e.target.value, + callbackStatus: undefined, + callbackError: undefined + }) + } + placeholder={t('auth_login.oauth_callback_placeholder')} + /> +
+ +
+ {state.callbackStatus === 'success' && ( +
+ {t('auth_login.oauth_callback_status_success')} +
+ )} + {state.callbackStatus === 'error' && ( +
+ {t('auth_login.oauth_callback_status_error')} {state.callbackError || ''} +
+ )} +
+ )} + {state.status && state.status !== 'idle' && (
{state.status === 'success' ? t('auth_login.codex_oauth_status_success') diff --git a/src/services/api/oauth.ts b/src/services/api/oauth.ts index 762cbd3..5e48472 100644 --- a/src/services/api/oauth.ts +++ b/src/services/api/oauth.ts @@ -17,6 +17,10 @@ export interface OAuthStartResponse { state?: string; } +export interface OAuthCallbackResponse { + status: 'ok'; +} + export interface IFlowCookieAuthResponse { status: 'ok' | 'error'; error?: string; @@ -27,18 +31,37 @@ export interface IFlowCookieAuthResponse { } const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow']; +const CALLBACK_PROVIDER_MAP: Partial> = { + 'gemini-cli': 'gemini' +}; export const oauthApi = { - startAuth: (provider: OAuthProvider) => - apiClient.get(`/${provider}-auth-url`, { - params: WEBUI_SUPPORTED.includes(provider) ? { is_webui: true } : undefined - }), + startAuth: (provider: OAuthProvider, options?: { projectId?: string }) => { + const params: Record = {}; + if (WEBUI_SUPPORTED.includes(provider)) { + params.is_webui = true; + } + if (provider === 'gemini-cli' && options?.projectId) { + params.project_id = options.projectId; + } + return apiClient.get(`/${provider}-auth-url`, { + params: Object.keys(params).length ? params : undefined + }); + }, getAuthStatus: (state: string) => apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, { params: { state } }), + submitCallback: (provider: OAuthProvider, redirectUrl: string) => { + const callbackProvider = CALLBACK_PROVIDER_MAP[provider] ?? provider; + return apiClient.post('/oauth-callback', { + provider: callbackProvider, + redirect_url: redirectUrl + }); + }, + /** iFlow cookie 认证 */ iflowCookieAuth: (cookie: string) => apiClient.post('/iflow-auth-url', { cookie })