diff --git a/src/features/authFiles/constants.ts b/src/features/authFiles/constants.ts index 23d85c8..3f4f370 100644 --- a/src/features/authFiles/constants.ts +++ b/src/features/authFiles/constants.ts @@ -3,6 +3,7 @@ import iconAntigravity from '@/assets/icons/antigravity.svg'; import iconClaude from '@/assets/icons/claude.svg'; import iconCodex from '@/assets/icons/codex.svg'; import iconGemini from '@/assets/icons/gemini.svg'; +import iconGrok from '@/assets/icons/grok.svg'; import iconIflow from '@/assets/icons/iflow.svg'; import iconKimiDark from '@/assets/icons/kimi-dark.svg'; import iconKimiLight from '@/assets/icons/kimi-light.svg'; @@ -82,6 +83,11 @@ export const TYPE_COLORS: Record = { light: { bg: '#e0f7fa', text: '#006064' }, dark: { bg: '#004d40', text: '#80deea' }, }, + // xAI / Grok: neutral graphite, kept distinct from the blue and purple providers + xai: { + light: { bg: '#eceff3', text: '#15181d' }, + dark: { bg: '#e5e7eb', text: '#111827' }, + }, // iFlow logo: 品红紫渐变 #5C5CFF → #AE5CFF,偏品红以区别于 Qwen 的紫罗兰 iflow: { light: { bg: '#f5e3fc', text: '#9025c8' }, @@ -109,6 +115,7 @@ export const AUTH_FILE_ICONS: Record = { codex: iconCodex, gemini: iconGemini, 'gemini-cli': iconGemini, + xai: iconGrok, iflow: iconIflow, kimi: { light: iconKimiLight, dark: iconKimiDark }, qwen: iconQwen, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 922e1ee..efb38ee 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -540,6 +540,7 @@ "filter_claude": "Claude", "filter_codex": "Codex", "filter_antigravity": "Antigravity", + "filter_xai": "xAI", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "Empty", @@ -552,6 +553,7 @@ "type_claude": "Claude", "type_codex": "Codex", "type_antigravity": "Antigravity", + "type_xai": "xAI", "type_iflow": "iFlow", "type_vertex": "Vertex", "type_empty": "Empty", @@ -901,6 +903,22 @@ "kimi_oauth_status_error": "Authentication failed:", "kimi_oauth_start_error": "Failed to start Kimi OAuth:", "kimi_oauth_polling_error": "Failed to check authentication status:", + "xai_oauth_title": "xAI OAuth", + "xai_oauth_button": "Start xAI Login", + "xai_oauth_hint": "Login to xAI Grok through OAuth flow, automatically obtain and save authentication files.", + "xai_oauth_url_label": "Authorization URL:", + "xai_open_link": "Open Link", + "xai_copy_link": "Copy Link", + "xai_oauth_status_waiting": "Waiting for authentication...", + "xai_oauth_status_success": "Authentication successful!", + "xai_oauth_status_error": "Authentication failed:", + "xai_oauth_start_error": "Failed to start xAI OAuth:", + "xai_oauth_polling_error": "Failed to check authentication status:", + "xai_callback_label": "Callback URL or code", + "xai_callback_placeholder": "Paste the code shown by Grok, or the full callback URL", + "xai_callback_hint": "Grok may only show a code on the page. Paste that code directly and the app will submit it as http://127.0.0.1:56121/callback?... automatically.", + "xai_callback_required": "Please paste the code shown by Grok or the full callback URL first.", + "xai_callback_state_missing": "Missing the state for this xAI login. Start xAI login again, then submit the code.", "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.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f903eae..ac66e2a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -540,6 +540,7 @@ "filter_claude": "Claude", "filter_codex": "Codex", "filter_antigravity": "Antigravity", + "filter_xai": "xAI", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "Пусто", @@ -552,6 +553,7 @@ "type_claude": "Claude", "type_codex": "Codex", "type_antigravity": "Antigravity", + "type_xai": "xAI", "type_iflow": "iFlow", "type_vertex": "Vertex", "type_empty": "Пусто", @@ -898,6 +900,22 @@ "kimi_oauth_status_error": "Ошибка аутентификации:", "kimi_oauth_start_error": "Не удалось запустить Kimi OAuth:", "kimi_oauth_polling_error": "Не удалось проверить статус аутентификации:", + "xai_oauth_title": "xAI OAuth", + "xai_oauth_button": "Начать вход xAI", + "xai_oauth_hint": "Выполните вход в xAI Grok через OAuth и автоматически получите/сохраните файлы авторизации.", + "xai_oauth_url_label": "URL авторизации:", + "xai_open_link": "Открыть ссылку", + "xai_copy_link": "Скопировать ссылку", + "xai_oauth_status_waiting": "Ожидание аутентификации...", + "xai_oauth_status_success": "Аутентификация успешна!", + "xai_oauth_status_error": "Ошибка аутентификации:", + "xai_oauth_start_error": "Не удалось запустить xAI OAuth:", + "xai_oauth_polling_error": "Не удалось проверить статус аутентификации:", + "xai_callback_label": "Callback URL или код", + "xai_callback_placeholder": "Вставьте код со страницы Grok или полный callback URL", + "xai_callback_hint": "Grok иногда показывает только код на странице. Вставьте этот код напрямую, приложение автоматически отправит его как http://127.0.0.1:56121/callback?...", + "xai_callback_required": "Сначала вставьте код со страницы Grok или полный callback URL.", + "xai_callback_state_missing": "Отсутствует state для этого входа xAI. Запустите вход xAI заново и затем отправьте код.", "oauth_callback_label": "Callback URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "Режим удалённого браузера: после перенаправления провайдера на http://localhost:... скопируйте полный URL и отправьте его здесь.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index cb4be8c..4af342d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -540,6 +540,7 @@ "filter_claude": "Claude", "filter_codex": "Codex", "filter_antigravity": "Antigravity", + "filter_xai": "xAI", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "空文件", @@ -552,6 +553,7 @@ "type_claude": "Claude", "type_codex": "Codex", "type_antigravity": "Antigravity", + "type_xai": "xAI", "type_iflow": "iFlow", "type_vertex": "Vertex", "type_empty": "空文件", @@ -901,6 +903,22 @@ "kimi_oauth_status_error": "认证失败:", "kimi_oauth_start_error": "启动 Kimi OAuth 失败:", "kimi_oauth_polling_error": "检查认证状态失败:", + "xai_oauth_title": "xAI OAuth", + "xai_oauth_button": "开始 xAI 登录", + "xai_oauth_hint": "通过 OAuth 流程登录 xAI Grok 服务,自动获取并保存认证文件。", + "xai_oauth_url_label": "授权链接:", + "xai_open_link": "打开链接", + "xai_copy_link": "复制链接", + "xai_oauth_status_waiting": "等待认证中...", + "xai_oauth_status_success": "认证成功!", + "xai_oauth_status_error": "认证失败:", + "xai_oauth_start_error": "启动 xAI OAuth 失败:", + "xai_oauth_polling_error": "检查认证状态失败:", + "xai_callback_label": "回调 URL 或授权码", + "xai_callback_placeholder": "粘贴页面显示的 code,或完整 callback URL", + "xai_callback_hint": "Grok 有时只在页面显示 code。可直接粘贴 code,系统会自动拼接为 http://127.0.0.1:56121/callback?... 后提交。", + "xai_callback_required": "请先粘贴 Grok 页面显示的 code 或完整回调 URL。", + "xai_callback_state_missing": "缺少本次 xAI 登录的 state,请重新开始 xAI 登录后再提交 code。", "oauth_callback_label": "回调 URL", "oauth_callback_placeholder": "http://localhost:1455/auth/callback?code=...&state=...", "oauth_callback_hint": "远程浏览器模式:当授权跳转到 http://localhost:... 后,复制完整 URL 并提交到这里。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 59b4258..ec31ad9 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -540,6 +540,7 @@ "filter_claude": "Claude", "filter_codex": "Codex", "filter_antigravity": "Antigravity", + "filter_xai": "xAI", "filter_iflow": "iFlow", "filter_vertex": "Vertex", "filter_empty": "空檔案", @@ -552,6 +553,7 @@ "type_claude": "Claude", "type_codex": "Codex", "type_antigravity": "Antigravity", + "type_xai": "xAI", "type_iflow": "iFlow", "type_vertex": "Vertex", "type_empty": "空檔案", @@ -901,6 +903,22 @@ "kimi_oauth_status_error": "驗證失敗:", "kimi_oauth_start_error": "啟動 Kimi OAuth 失敗:", "kimi_oauth_polling_error": "檢查驗證狀態失敗:", + "xai_oauth_title": "xAI OAuth", + "xai_oauth_button": "開始 xAI 登入", + "xai_oauth_hint": "透過 OAuth 流程登入 xAI Grok 服務,自動取得並儲存驗證檔案。", + "xai_oauth_url_label": "授權連結:", + "xai_open_link": "開啟連結", + "xai_copy_link": "複製連結", + "xai_oauth_status_waiting": "等待驗證中...", + "xai_oauth_status_success": "驗證成功!", + "xai_oauth_status_error": "驗證失敗:", + "xai_oauth_start_error": "啟動 xAI OAuth 失敗:", + "xai_oauth_polling_error": "檢查驗證狀態失敗:", + "xai_callback_label": "回調 URL 或授權碼", + "xai_callback_placeholder": "貼上頁面顯示的 code,或完整 callback URL", + "xai_callback_hint": "Grok 有時只在頁面顯示 code。可直接貼上 code,系統會自動拼接為 http://127.0.0.1:56121/callback?... 後提交。", + "xai_callback_required": "請先貼上 Grok 頁面顯示的 code 或完整回調 URL。", + "xai_callback_state_missing": "缺少本次 xAI 登入的 state,請重新開始 xAI 登入後再提交 code。", "qwen_oauth_title": "Qwen OAuth", "qwen_oauth_button": "開始 Qwen 登入", "qwen_oauth_hint": "透過裝置授權流程登入 Qwen 服務,自動取得並儲存驗證檔案。", diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index ebcffa9..9b01481 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -16,6 +16,7 @@ import iconGemini from '@/assets/icons/gemini.svg'; import iconKimiLight from '@/assets/icons/kimi-light.svg'; import iconKimiDark from '@/assets/icons/kimi-dark.svg'; import iconVertex from '@/assets/icons/vertex.svg'; +import iconGrok from '@/assets/icons/grok.svg'; interface ProviderState { url?: string; @@ -67,10 +68,18 @@ const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabe { id: 'anthropic', titleKey: 'auth_login.anthropic_oauth_title', hintKey: 'auth_login.anthropic_oauth_hint', urlLabelKey: 'auth_login.anthropic_oauth_url_label', icon: iconClaude }, { id: 'antigravity', titleKey: 'auth_login.antigravity_oauth_title', hintKey: 'auth_login.antigravity_oauth_hint', urlLabelKey: 'auth_login.antigravity_oauth_url_label', icon: iconAntigravity }, { id: 'gemini-cli', titleKey: 'auth_login.gemini_cli_oauth_title', hintKey: 'auth_login.gemini_cli_oauth_hint', urlLabelKey: 'auth_login.gemini_cli_oauth_url_label', icon: iconGemini }, - { id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } } + { id: 'kimi', titleKey: 'auth_login.kimi_oauth_title', hintKey: 'auth_login.kimi_oauth_hint', urlLabelKey: 'auth_login.kimi_oauth_url_label', icon: { light: iconKimiLight, dark: iconKimiDark } }, + { id: 'xai', titleKey: 'auth_login.xai_oauth_title', hintKey: 'auth_login.xai_oauth_hint', urlLabelKey: 'auth_login.xai_oauth_url_label', icon: iconGrok } ]; -const CALLBACK_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli']; +const CALLBACK_SUPPORTED: OAuthProvider[] = [ + 'codex', + 'anthropic', + 'antigravity', + 'gemini-cli', + 'xai' +]; +const XAI_CALLBACK_URL = 'http://127.0.0.1:56121/callback'; const SUCCESS_RESET_DELAY_MS = 5000; const getProviderI18nPrefix = (provider: OAuthProvider) => provider.replace('-', '_'); const getAuthKey = (provider: OAuthProvider, suffix: string) => @@ -80,6 +89,77 @@ const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' return typeof icon === 'string' ? icon : icon[theme]; }; +const isAbsoluteUrl = (value: string): boolean => { + try { + new URL(value); + return true; + } catch { + return false; + } +}; + +const readQueryLikeCallbackInput = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return null; + const queryStart = trimmed.indexOf('?'); + const hashStart = trimmed.indexOf('#'); + const rawParams = + queryStart >= 0 + ? trimmed.slice(queryStart + 1) + : hashStart >= 0 + ? trimmed.slice(hashStart + 1) + : trimmed; + + if (!/(^|[&#?])(code|state|error)=/i.test(rawParams)) return null; + return new URLSearchParams(rawParams.replace(/^[?#]/, '')); +}; + +const extractDisplayedXaiCode = (value: string): string => { + const trimmed = value.trim(); + const codeMatch = trimmed.match(/\bcode\s*[:=]\s*([^\s&]+)/i); + return (codeMatch?.[1] ?? trimmed).trim(); +}; + +const buildXaiCallbackUrl = (input: string, state?: string): string | null => { + const trimmed = input.trim(); + if (!trimmed) return null; + if (isAbsoluteUrl(trimmed)) return trimmed; + + const params = readQueryLikeCallbackInput(trimmed); + if (params) { + const code = params.get('code')?.trim(); + const error = params.get('error')?.trim(); + const errorDescription = params.get('error_description')?.trim(); + const callbackState = params.get('state')?.trim() || state?.trim(); + if (!callbackState) return null; + + const callbackUrl = new URL(XAI_CALLBACK_URL); + callbackUrl.searchParams.set('state', callbackState); + if (code) callbackUrl.searchParams.set('code', code); + if (error) callbackUrl.searchParams.set('error', error); + if (errorDescription) callbackUrl.searchParams.set('error_description', errorDescription); + return callbackUrl.toString(); + } + + const code = extractDisplayedXaiCode(trimmed); + const callbackState = state?.trim(); + if (!code || !callbackState) return null; + + const callbackUrl = new URL(XAI_CALLBACK_URL); + callbackUrl.searchParams.set('code', code); + callbackUrl.searchParams.set('state', callbackState); + return callbackUrl.toString(); +}; + +const resolveCallbackUrl = ( + provider: OAuthProvider, + input: string, + state?: string +): string | null => { + if (provider !== 'xai') return input.trim(); + return buildXaiCallbackUrl(input, state); +}; + export function OAuthPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -262,9 +342,17 @@ export function OAuthPage() { }; const submitCallback = async (provider: OAuthProvider) => { - const redirectUrl = (states[provider]?.callbackUrl || '').trim(); + const callbackInput = (states[provider]?.callbackUrl || '').trim(); + if (!callbackInput) { + showNotification( + t(provider === 'xai' ? 'auth_login.xai_callback_required' : 'auth_login.oauth_callback_required'), + 'warning' + ); + return; + } + const redirectUrl = resolveCallbackUrl(provider, callbackInput, states[provider]?.state); if (!redirectUrl) { - showNotification(t('auth_login.oauth_callback_required'), 'warning'); + showNotification(t(provider === 'xai' ? 'auth_login.xai_callback_state_missing' : 'auth_login.missing_state'), 'warning'); return; } updateProviderState(provider, { @@ -434,8 +522,16 @@ export function OAuthPage() { {canSubmitCallback && (
updateProviderState(provider.id, { @@ -444,7 +540,11 @@ export function OAuthPage() { callbackError: undefined }) } - placeholder={t('auth_login.oauth_callback_placeholder')} + placeholder={t( + provider.id === 'xai' + ? 'auth_login.xai_callback_placeholder' + : 'auth_login.oauth_callback_placeholder' + )} />