From e3ca3aa4f6e6939ef26802394d17b9fd953fb25f Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Wed, 24 Jun 2026 21:23:57 +0800 Subject: [PATCH] feat: add plugin OAuth support with UI enhancements and localization updates --- src/i18n/locales/en.json | 10 + src/i18n/locales/ru.json | 10 + src/i18n/locales/zh-CN.json | 10 + src/i18n/locales/zh-TW.json | 10 + src/pages/OAuthPage.module.scss | 9 + src/pages/OAuthPage.tsx | 323 ++++++++++++++++++++++++-------- src/services/api/oauth.ts | 48 ++--- src/services/api/plugins.ts | 28 ++- src/types/oauth.ts | 11 +- src/types/plugin.ts | 1 + src/utils/providerKeys.ts | 8 + 11 files changed, 356 insertions(+), 112 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ac5037b8..a5046c8d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -700,6 +700,16 @@ "xai_oauth_status_error": "Authentication failed:", "xai_oauth_start_error": "Failed to start xAI OAuth:", "xai_oauth_polling_error": "Failed to check authentication status:", + "plugin_oauth_title": "{{name}} OAuth", + "plugin_oauth_button": "Start {{name}} Login", + "plugin_oauth_hint": "Log in to {{name}} through the plugin OAuth flow, automatically obtain and save authentication files.", + "plugin_oauth_url_label": "Authorization URL:", + "plugin_open_link": "Open Link", + "plugin_copy_link": "Copy Link", + "plugin_oauth_status_waiting": "Waiting for authentication...", + "plugin_oauth_status_success": "Authentication successful!", + "plugin_oauth_status_error": "Authentication failed:", + "plugin_oauth_start_error": "Failed to start plugin OAuth:", "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.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d71c2c8c..4ddff058 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -688,6 +688,16 @@ "xai_oauth_status_error": "Ошибка аутентификации:", "xai_oauth_start_error": "Не удалось запустить xAI OAuth:", "xai_oauth_polling_error": "Не удалось проверить статус аутентификации:", + "plugin_oauth_title": "{{name}} OAuth", + "plugin_oauth_button": "Начать вход {{name}}", + "plugin_oauth_hint": "Выполните вход в {{name}} через OAuth-поток плагина и автоматически получите/сохраните файлы авторизации.", + "plugin_oauth_url_label": "URL авторизации:", + "plugin_open_link": "Открыть ссылку", + "plugin_copy_link": "Скопировать ссылку", + "plugin_oauth_status_waiting": "Ожидание аутентификации...", + "plugin_oauth_status_success": "Аутентификация успешна!", + "plugin_oauth_status_error": "Ошибка аутентификации:", + "plugin_oauth_start_error": "Не удалось запустить OAuth плагина:", "xai_callback_label": "Callback URL или код", "xai_callback_placeholder": "Вставьте код со страницы Grok или полный callback URL", "xai_callback_hint": "Grok иногда показывает только код на странице. Вставьте этот код напрямую, приложение автоматически отправит его как http://127.0.0.1:56121/callback?...", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index c655960c..5fa9c6c5 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -700,6 +700,16 @@ "xai_oauth_status_error": "认证失败:", "xai_oauth_start_error": "启动 xAI OAuth 失败:", "xai_oauth_polling_error": "检查认证状态失败:", + "plugin_oauth_title": "{{name}} OAuth", + "plugin_oauth_button": "开始 {{name}} 登录", + "plugin_oauth_hint": "通过插件提供的 OAuth 流程登录 {{name}},自动获取并保存认证文件。", + "plugin_oauth_url_label": "授权链接:", + "plugin_open_link": "打开链接", + "plugin_copy_link": "复制链接", + "plugin_oauth_status_waiting": "等待认证中...", + "plugin_oauth_status_success": "认证成功!", + "plugin_oauth_status_error": "认证失败:", + "plugin_oauth_start_error": "启动插件 OAuth 失败:", "xai_callback_label": "回调 URL 或授权码", "xai_callback_placeholder": "粘贴页面显示的 code,或完整 callback URL", "xai_callback_hint": "Grok 有时只在页面显示 code。可直接粘贴 code,系统会自动拼接为 http://127.0.0.1:56121/callback?... 后提交。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 402e525a..40452d5a 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -700,6 +700,16 @@ "xai_oauth_status_error": "驗證失敗:", "xai_oauth_start_error": "啟動 xAI OAuth 失敗:", "xai_oauth_polling_error": "檢查驗證狀態失敗:", + "plugin_oauth_title": "{{name}} OAuth", + "plugin_oauth_button": "開始 {{name}} 登入", + "plugin_oauth_hint": "透過插件提供的 OAuth 流程登入 {{name}},自動取得並儲存驗證檔案。", + "plugin_oauth_url_label": "授權連結:", + "plugin_open_link": "開啟連結", + "plugin_copy_link": "複製連結", + "plugin_oauth_status_waiting": "等待驗證中...", + "plugin_oauth_status_success": "驗證成功!", + "plugin_oauth_status_error": "驗證失敗:", + "plugin_oauth_start_error": "啟動插件 OAuth 失敗:", "xai_callback_label": "回調 URL 或授權碼", "xai_callback_placeholder": "貼上頁面顯示的 code,或完整 callback URL", "xai_callback_hint": "Grok 有時只在頁面顯示 code。可直接貼上 code,系統會自動拼接為 http://127.0.0.1:56121/callback?... 後提交。", diff --git a/src/pages/OAuthPage.module.scss b/src/pages/OAuthPage.module.scss index d6ece815..d03121c1 100644 --- a/src/pages/OAuthPage.module.scss +++ b/src/pages/OAuthPage.module.scss @@ -15,6 +15,15 @@ height: 24px; } +.cardTitleIconFallback { + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); +} + .pageTitle { font-size: 28px; font-weight: 700; diff --git a/src/pages/OAuthPage.tsx b/src/pages/OAuthPage.tsx index 0ec56d42..f2620152 100644 --- a/src/pages/OAuthPage.tsx +++ b/src/pages/OAuthPage.tsx @@ -1,14 +1,17 @@ -import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; -import { useNotificationStore, useThemeStore } from '@/stores'; -import { oauthApi, type OAuthProvider } from '@/services/api/oauth'; +import { IconPlug } from '@/components/ui/icons'; +import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; +import { oauthApi, pluginsApi, type BuiltInOAuthProvider } from '@/services/api'; import { vertexApi, type VertexImportResponse } from '@/services/api/vertex'; import { copyToClipboard } from '@/utils/clipboard'; import { getErrorMessage, isRecord } from '@/utils/helpers'; +import { getPluginTitle, resolvePluginAssetURL } from '@/features/plugins/pluginResources'; +import type { PluginListEntry } from '@/types'; import styles from './OAuthPage.module.scss'; import iconCodex from '@/assets/icons/codex.svg'; import iconClaude from '@/assets/icons/claude.svg'; @@ -47,35 +50,138 @@ interface VertexImportState { result?: VertexImportResult; } +interface BuiltInOAuthProviderCard { + kind: 'builtin'; + id: BuiltInOAuthProvider; + titleKey: string; + hintKey: string; + urlLabelKey: string; + icon: string | { light: string; dark: string }; +} + +interface PluginOAuthProviderCard { + kind: 'plugin'; + id: string; + title: string; + icon: string; +} + +type OAuthProviderCard = BuiltInOAuthProviderCard | PluginOAuthProviderCard; + function getErrorStatus(error: unknown): number | undefined { if (!isRecord(error)) return undefined; return typeof error.status === 'number' ? error.status : undefined; } -const PROVIDERS: { id: OAuthProvider; titleKey: string; hintKey: string; urlLabelKey: string; icon: string | { light: string; dark: string } }[] = [ - { id: 'codex', titleKey: 'auth_login.codex_oauth_title', hintKey: 'auth_login.codex_oauth_hint', urlLabelKey: 'auth_login.codex_oauth_url_label', icon: iconCodex }, - { 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: '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: { light: iconGrok, dark: iconGrokDark } } +const PROVIDERS: BuiltInOAuthProviderCard[] = [ + { + kind: 'builtin', + id: 'codex', + titleKey: 'auth_login.codex_oauth_title', + hintKey: 'auth_login.codex_oauth_hint', + urlLabelKey: 'auth_login.codex_oauth_url_label', + icon: iconCodex, + }, + { + kind: 'builtin', + id: 'anthropic', + titleKey: 'auth_login.anthropic_oauth_title', + hintKey: 'auth_login.anthropic_oauth_hint', + urlLabelKey: 'auth_login.anthropic_oauth_url_label', + icon: iconClaude, + }, + { + kind: 'builtin', + id: 'antigravity', + titleKey: 'auth_login.antigravity_oauth_title', + hintKey: 'auth_login.antigravity_oauth_hint', + urlLabelKey: 'auth_login.antigravity_oauth_url_label', + icon: iconAntigravity, + }, + { + kind: 'builtin', + 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 }, + }, + { + kind: 'builtin', + id: 'xai', + titleKey: 'auth_login.xai_oauth_title', + hintKey: 'auth_login.xai_oauth_hint', + urlLabelKey: 'auth_login.xai_oauth_url_label', + icon: { light: iconGrok, dark: iconGrokDark }, + }, ]; -const CALLBACK_SUPPORTED: OAuthProvider[] = [ - 'codex', - 'anthropic', - 'antigravity', - 'xai' -]; +const BUILTIN_PROVIDER_IDS = new Set(PROVIDERS.map((provider) => provider.id)); +const CALLBACK_SUPPORTED = new Set(['codex', 'anthropic', 'antigravity', '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) => +const getProviderI18nPrefix = (provider: string) => provider.replace('-', '_'); +const getAuthKey = (provider: string, suffix: string) => `auth_login.${getProviderI18nPrefix(provider)}_${suffix}`; const getIcon = (icon: string | { light: string; dark: string }, theme: 'light' | 'dark') => { return typeof icon === 'string' ? icon : icon[theme]; }; +function PluginOAuthIcon({ src }: { src: string }) { + const [failed, setFailed] = useState(false); + if (src && !failed) { + return ( + setFailed(true)} /> + ); + } + return ( + + ); +} + +function OAuthProviderIcon({ + provider, + theme, +}: { + provider: OAuthProviderCard; + theme: 'light' | 'dark'; +}) { + if (provider.kind === 'plugin') { + return ; + } + return ; +} + +const buildPluginOAuthProviderCards = ( + plugins: PluginListEntry[], + apiBase: string +): PluginOAuthProviderCard[] => { + const seenProviders = new Set(BUILTIN_PROVIDER_IDS); + return plugins.flatMap((plugin) => { + const provider = plugin.oauthProvider; + if ( + !plugin.supportsOAuth || + !plugin.effectiveEnabled || + !provider || + seenProviders.has(provider) + ) { + return []; + } + seenProviders.add(provider); + return [ + { + kind: 'plugin' as const, + id: provider, + title: getPluginTitle(plugin), + icon: resolvePluginAssetURL(plugin.logo || plugin.metadata?.logo || '', apiBase), + }, + ]; + }); +}; + const isAbsoluteUrl = (value: string): boolean => { try { new URL(value); @@ -138,11 +244,7 @@ const buildXaiCallbackUrl = (input: string, state?: string): string | null => { return callbackUrl.toString(); }; -const resolveCallbackUrl = ( - provider: OAuthProvider, - input: string, - state?: string -): string | null => { +const resolveCallbackUrl = (provider: string, input: string, state?: string): string | null => { if (provider !== 'xai') return input.trim(); return buildXaiCallbackUrl(input, state); }; @@ -150,16 +252,18 @@ const resolveCallbackUrl = ( export function OAuthPage() { const { t } = useTranslation(); const navigate = useNavigate(); + const apiBase = useAuthStore((state) => state.apiBase); const { showNotification } = useNotificationStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); - const [states, setStates] = useState>({} as Record); + const [states, setStates] = useState>({}); + const [pluginProviders, setPluginProviders] = useState([]); const [vertexState, setVertexState] = useState({ fileName: '', location: '', - loading: false + loading: false, }); - const pollingTimers = useRef>>({}); - const successResetTimers = useRef>>({}); + const pollingTimers = useRef>>({}); + const successResetTimers = useRef>>({}); const vertexFileInputRef = useRef(null); const clearTimers = useCallback(() => { @@ -179,14 +283,57 @@ export function OAuthPage() { }; }, [clearTimers]); - const updateProviderState = (provider: OAuthProvider, next: Partial) => { + useEffect(() => { + let cancelled = false; + + const loadPluginProviders = async () => { + try { + const response = await pluginsApi.list(); + if (!cancelled) { + setPluginProviders(buildPluginOAuthProviderCards(response.plugins, apiBase)); + } + } catch { + if (!cancelled) { + setPluginProviders([]); + } + } + }; + + void loadPluginProviders(); + + return () => { + cancelled = true; + }; + }, [apiBase]); + + const providerCards = useMemo( + () => [...PROVIDERS, ...pluginProviders], + [pluginProviders] + ); + + const getProviderTitleText = (provider: OAuthProviderCard) => + provider.kind === 'plugin' + ? t('auth_login.plugin_oauth_title', { name: provider.title }) + : t(provider.titleKey); + + const getProviderText = (provider: OAuthProviderCard, suffix: string) => + provider.kind === 'plugin' + ? t(`auth_login.plugin_${suffix}`, { name: provider.title }) + : t(getAuthKey(provider.id, suffix)); + + const getProviderTextByID = (provider: string, suffix: string) => { + const card = providerCards.find((item) => item.id === provider); + return card ? getProviderText(card, suffix) : t(getAuthKey(provider, suffix)); + }; + + const updateProviderState = (provider: string, next: Partial) => { setStates((prev) => ({ ...prev, - [provider]: { ...(prev[provider] ?? {}), ...next } + [provider]: { ...(prev[provider] ?? {}), ...next }, })); }; - const clearPollingTimer = (provider: OAuthProvider) => { + const clearPollingTimer = (provider: string) => { const timer = pollingTimers.current[provider]; if (timer !== undefined) { window.clearInterval(timer); @@ -194,7 +341,7 @@ export function OAuthPage() { } }; - const clearSuccessResetTimer = (provider: OAuthProvider) => { + const clearSuccessResetTimer = (provider: string) => { const timer = successResetTimers.current[provider]; if (timer !== undefined) { window.clearTimeout(timer); @@ -202,22 +349,22 @@ export function OAuthPage() { } }; - const clearProviderTimers = (provider: OAuthProvider) => { + const clearProviderTimers = (provider: string) => { clearPollingTimer(provider); clearSuccessResetTimer(provider); }; - const resetProviderAttempt = (provider: OAuthProvider) => { + const resetProviderAttempt = (provider: string) => { clearProviderTimers(provider); setStates((prev) => { return { ...prev, - [provider]: {} + [provider]: {}, }; }); }; - const completeProviderAuth = (provider: OAuthProvider) => { + const completeProviderAuth = (provider: string) => { clearPollingTimer(provider); clearSuccessResetTimer(provider); updateProviderState(provider, { @@ -229,32 +376,36 @@ export function OAuthPage() { callbackUrl: '', callbackSubmitting: false, callbackStatus: undefined, - callbackError: undefined + callbackError: undefined, }); successResetTimers.current[provider] = window.setTimeout(() => { resetProviderAttempt(provider); }, SUCCESS_RESET_DELAY_MS); }; - const startPolling = (provider: OAuthProvider, state: string) => { + const startPolling = (provider: string, state: string) => { clearPollingTimer(provider); const timer = window.setInterval(async () => { try { const res = await oauthApi.getAuthStatus(state); if (res.status === 'ok') { completeProviderAuth(provider); - showNotification(t(getAuthKey(provider, 'oauth_status_success')), 'success'); + showNotification(getProviderTextByID(provider, 'oauth_status_success'), 'success'); } else if (res.status === 'error') { updateProviderState(provider, { status: 'error', error: res.error, polling: false }); showNotification( - `${t(getAuthKey(provider, 'oauth_status_error'))} ${res.error || ''}`, + `${getProviderTextByID(provider, 'oauth_status_error')} ${res.error || ''}`, 'error' ); window.clearInterval(timer); delete pollingTimers.current[provider]; } } catch (err: unknown) { - updateProviderState(provider, { status: 'error', error: getErrorMessage(err), polling: false }); + updateProviderState(provider, { + status: 'error', + error: getErrorMessage(err), + polling: false, + }); window.clearInterval(timer); delete pollingTimers.current[provider]; } @@ -262,7 +413,7 @@ export function OAuthPage() { pollingTimers.current[provider] = timer; }; - const startAuth = async (provider: OAuthProvider) => { + const startAuth = async (provider: string) => { clearProviderTimers(provider); updateProviderState(provider, { url: undefined, @@ -272,7 +423,7 @@ export function OAuthPage() { error: undefined, callbackStatus: undefined, callbackError: undefined, - callbackUrl: '' + callbackUrl: '', }); try { const res = await oauthApi.startAuth(provider); @@ -283,18 +434,23 @@ export function OAuthPage() { state: undefined, status: 'error', error: message, - polling: false + polling: false, }); showNotification(message, 'error'); return; } - updateProviderState(provider, { url: res.url, state: res.state, status: 'waiting', polling: true }); + updateProviderState(provider, { + url: res.url, + state: res.state, + status: 'waiting', + polling: true, + }); startPolling(provider, res.state); } catch (err: unknown) { const message = getErrorMessage(err); updateProviderState(provider, { status: 'error', error: message, polling: false }); showNotification( - `${t(getAuthKey(provider, 'oauth_start_error'))}${message ? ` ${message}` : ''}`, + `${getProviderTextByID(provider, 'oauth_start_error')}${message ? ` ${message}` : ''}`, 'error' ); } @@ -309,24 +465,33 @@ export function OAuthPage() { ); }; - const submitCallback = async (provider: OAuthProvider) => { + const submitCallback = async (provider: string) => { const callbackInput = (states[provider]?.callbackUrl || '').trim(); if (!callbackInput) { showNotification( - t(provider === 'xai' ? 'auth_login.xai_callback_required' : 'auth_login.oauth_callback_required'), + 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(provider === 'xai' ? 'auth_login.xai_callback_state_missing' : 'auth_login.missing_state'), 'warning'); + showNotification( + t( + provider === 'xai' ? 'auth_login.xai_callback_state_missing' : 'auth_login.missing_state' + ), + 'warning' + ); return; } updateProviderState(provider, { callbackSubmitting: true, callbackStatus: undefined, - callbackError: undefined + callbackError: undefined, }); try { await oauthApi.submitCallback(provider, redirectUrl); @@ -338,13 +503,13 @@ export function OAuthPage() { const errorMessage = status === 404 ? t('auth_login.oauth_callback_upgrade_hint', { - defaultValue: 'Please update CLI Proxy API or check the connection.' + defaultValue: 'Please update CLI Proxy API or check the connection.', }) : message || undefined; updateProviderState(provider, { callbackSubmitting: false, callbackStatus: 'error', - callbackError: errorMessage + callbackError: errorMessage, }); const notificationMessage = errorMessage ? `${t('auth_login.oauth_callback_error')} ${errorMessage}` @@ -370,7 +535,7 @@ export function OAuthPage() { file, fileName: file.name, error: undefined, - result: undefined + result: undefined, })); event.target.value = ''; }; @@ -393,7 +558,7 @@ export function OAuthPage() { projectId: res.project_id, email: res.email, location: res.location, - authFile: res['auth-file'] ?? res.auth_file + authFile: res['auth-file'] ?? res.auth_file, }; setVertexState((prev) => ({ ...prev, loading: false, result })); showNotification(t('vertex_import.success'), 'success'); @@ -402,7 +567,7 @@ export function OAuthPage() { setVertexState((prev) => ({ ...prev, loading: false, - error: message || t('notification.upload_failed') + error: message || t('notification.upload_failed'), })); const notification = message ? `${t('notification.upload_failed')}: ${message}` @@ -416,17 +581,19 @@ export function OAuthPage() {

{t('nav.oauth', { defaultValue: 'OAuth' })}

- {PROVIDERS.map((provider) => { + {providerCards.map((provider) => { const state = states[provider.id] || {}; - const canSubmitCallback = CALLBACK_SUPPORTED.includes(provider.id) && Boolean(state.url); + const canSubmitCallback = + (provider.kind === 'plugin' || CALLBACK_SUPPORTED.has(provider.id)) && + Boolean(state.url); const loginButtonLabel = state.status === 'success' ? t('auth_login.login_another_account') - : t(getAuthKey(provider.id, 'oauth_button')); + : getProviderText(provider, 'oauth_button'); const statusBadgeClassName = [ 'status-badge', state.status === 'success' ? 'success' : '', - state.status === 'error' ? 'error' : '' + state.status === 'error' ? 'error' : '', ] .filter(Boolean) .join(' '); @@ -435,12 +602,8 @@ export function OAuthPage() { - - {t(provider.titleKey)} + + {getProviderTitleText(provider)} } extra={ @@ -450,21 +613,23 @@ export function OAuthPage() { } >
-
{t(provider.hintKey)}
+
{getProviderText(provider, 'oauth_hint')}
{state.url && (
-
{t(provider.urlLabelKey)}
+
+ {getProviderText(provider, 'oauth_url_label')} +
{state.url}
@@ -487,7 +652,7 @@ export function OAuthPage() { updateProviderState(provider.id, { callbackUrl: e.target.value, callbackStatus: undefined, - callbackError: undefined + callbackError: undefined, }) } placeholder={t( @@ -521,10 +686,10 @@ export function OAuthPage() { {state.status && state.status !== 'idle' && (
{state.status === 'success' - ? t(getAuthKey(provider.id, 'oauth_status_success')) + ? getProviderText(provider, 'oauth_status_success') : state.status === 'error' - ? `${t(getAuthKey(provider.id, 'oauth_status_error'))} ${state.error || ''}` - : t(getAuthKey(provider.id, 'oauth_status_waiting'))} + ? `${getProviderText(provider, 'oauth_status_error')} ${state.error || ''}` + : getProviderText(provider, 'oauth_status_waiting')}
)} {state.status === 'success' && ( @@ -563,7 +728,7 @@ export function OAuthPage() { onChange={(e) => setVertexState((prev) => ({ ...prev, - location: e.target.value + location: e.target.value, })) } placeholder={t('vertex_import.location_placeholder')} @@ -591,18 +756,16 @@ export function OAuthPage() { onChange={handleVertexFileChange} />
- {vertexState.error && ( -
- {vertexState.error} -
- )} + {vertexState.error &&
{vertexState.error}
} {vertexState.result && (
{t('vertex_import.result_title')}
{vertexState.result.projectId && (
- {t('vertex_import.result_project')} + + {t('vertex_import.result_project')} + {vertexState.result.projectId}
)} @@ -614,7 +777,9 @@ export function OAuthPage() { )} {vertexState.result.location && (
- {t('vertex_import.result_location')} + + {t('vertex_import.result_location')} + {vertexState.result.location}
)} diff --git a/src/services/api/oauth.ts b/src/services/api/oauth.ts index 02943907..f4d5e314 100644 --- a/src/services/api/oauth.ts +++ b/src/services/api/oauth.ts @@ -3,13 +3,14 @@ */ import { apiClient } from './client'; +import { + isManagementOAuthProviderKey, + normalizeManagementOAuthProviderKey, +} from '@/utils/providerKeys'; -export type OAuthProvider = - | 'codex' - | 'anthropic' - | 'antigravity' - | 'kimi' - | 'xai'; +export type BuiltInOAuthProvider = 'codex' | 'anthropic' | 'antigravity' | 'kimi' | 'xai'; + +export type OAuthProvider = string; export interface OAuthStartResponse { url: string; @@ -20,33 +21,38 @@ export interface OAuthCallbackResponse { status: 'ok'; } -const WEBUI_SUPPORTED: OAuthProvider[] = [ - 'codex', - 'anthropic', - 'antigravity', - 'xai' -]; +const WEBUI_SUPPORTED = new Set(['codex', 'anthropic', 'antigravity', 'xai']); + +const normalizeProviderForManagementPath = (provider: string): string => { + const key = normalizeManagementOAuthProviderKey(provider); + if (!isManagementOAuthProviderKey(key)) { + throw new Error('Invalid OAuth provider'); + } + return key; +}; export const oauthApi = { - startAuth: (provider: OAuthProvider) => { + startAuth: (provider: string) => { + const providerKey = normalizeProviderForManagementPath(provider); const params: Record = {}; - if (WEBUI_SUPPORTED.includes(provider)) { + if (WEBUI_SUPPORTED.has(providerKey)) { params.is_webui = true; } - return apiClient.get(`/${provider}-auth-url`, { - params: Object.keys(params).length ? params : undefined + return apiClient.get(`/${providerKey}-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 } + params: { state }, }), - submitCallback: (provider: OAuthProvider, redirectUrl: string) => { + submitCallback: (provider: string, redirectUrl: string) => { + const providerKey = normalizeProviderForManagementPath(provider); return apiClient.post('/oauth-callback', { - provider, - redirect_url: redirectUrl + provider: providerKey, + redirect_url: redirectUrl, }); - } + }, }; diff --git a/src/services/api/plugins.ts b/src/services/api/plugins.ts index 46c8ae1f..7b43c636 100644 --- a/src/services/api/plugins.ts +++ b/src/services/api/plugins.ts @@ -1,5 +1,9 @@ import { apiClient } from './client'; import { isRecord } from '@/utils/helpers'; +import { + isManagementOAuthProviderKey, + normalizeManagementOAuthProviderKey, +} from '@/utils/providerKeys'; import type { PluginConfigField, PluginConfigObject, @@ -21,6 +25,11 @@ const asString = (value: unknown): string => { const asBoolean = (value: unknown): boolean => value === true; +const normalizePluginOAuthProvider = (value: unknown): string | undefined => { + const provider = normalizeManagementOAuthProviderKey(asString(value)); + return isManagementOAuthProviderKey(provider) ? provider : undefined; +}; + const normalizeConfigField = (value: unknown): PluginConfigField | null => { if (!isRecord(value)) return null; const name = asString(value.name).trim(); @@ -38,7 +47,7 @@ const normalizeConfigField = (value: unknown): PluginConfigField | null => { const normalizeConfigFields = (value: unknown): PluginConfigField[] => Array.isArray(value) - ? value.map((item) => normalizeConfigField(item)).filter(Boolean) as PluginConfigField[] + ? (value.map((item) => normalizeConfigField(item)).filter(Boolean) as PluginConfigField[]) : []; const normalizeMetadata = (value: unknown): PluginMetadata | null => { @@ -78,7 +87,7 @@ const normalizeMenu = (value: unknown): PluginMenu | null => { const normalizeMenus = (value: unknown): PluginMenu[] => Array.isArray(value) - ? value.map((item) => normalizeMenu(item)).filter(Boolean) as PluginMenu[] + ? (value.map((item) => normalizeMenu(item)).filter(Boolean) as PluginMenu[]) : []; const normalizePluginEntry = (value: unknown): PluginListEntry | null => { @@ -97,8 +106,9 @@ const normalizePluginEntry = (value: unknown): PluginListEntry | null => { enabled: value.enabled !== false, effectiveEnabled: asBoolean(value.effective_enabled), supportsOAuth: asBoolean(value.supports_oauth), + oauthProvider: normalizePluginOAuthProvider(value.oauth_provider), logo: asString(value.logo || metadata?.logo).trim(), - configFields: configFields.length > 0 ? configFields : metadata?.configFields ?? [], + configFields: configFields.length > 0 ? configFields : (metadata?.configFields ?? []), menus: normalizeMenus(value.menus), metadata, }; @@ -107,7 +117,9 @@ const normalizePluginEntry = (value: unknown): PluginListEntry | null => { const normalizePluginList = (value: unknown): PluginListResponse => { const source = isRecord(value) ? value : {}; const plugins = Array.isArray(source.plugins) - ? source.plugins.map((item) => normalizePluginEntry(item)).filter(Boolean) as PluginListEntry[] + ? (source.plugins + .map((item) => normalizePluginEntry(item)) + .filter(Boolean) as PluginListEntry[]) : []; return { @@ -184,10 +196,14 @@ const normalizeStoreSource = (value: unknown): PluginStoreSource | null => { const normalizeStoreList = (value: unknown): PluginStoreResponse => { const source = isRecord(value) ? value : {}; const plugins = Array.isArray(source.plugins) - ? source.plugins.map((item) => normalizeStoreEntry(item)).filter(Boolean) as PluginStoreEntry[] + ? (source.plugins + .map((item) => normalizeStoreEntry(item)) + .filter(Boolean) as PluginStoreEntry[]) : []; const sources = Array.isArray(source.sources) - ? source.sources.map((item) => normalizeStoreSource(item)).filter(Boolean) as PluginStoreSource[] + ? (source.sources + .map((item) => normalizeStoreSource(item)) + .filter(Boolean) as PluginStoreSource[]) : []; return { diff --git a/src/types/oauth.ts b/src/types/oauth.ts index 7a00d163..158471bb 100644 --- a/src/types/oauth.ts +++ b/src/types/oauth.ts @@ -3,12 +3,11 @@ * 基于原项目 src/modules/oauth.js */ -// OAuth 提供商类型 -export type OAuthProvider = - | 'codex' - | 'anthropic' - | 'antigravity' - | 'kimi'; +// 内置 OAuth 提供商类型 +export type BuiltInOAuthProvider = 'codex' | 'anthropic' | 'antigravity' | 'kimi' | 'xai'; + +// OAuth provider 可来自内置通道或插件 AuthProvider identifier。 +export type OAuthProvider = string; // OAuth 流程状态 export interface OAuthFlow { diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 8766778b..ed459f60 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -39,6 +39,7 @@ export interface PluginListEntry { enabled: boolean; effectiveEnabled: boolean; supportsOAuth: boolean; + oauthProvider?: string; logo: string; configFields: PluginConfigField[]; menus: PluginMenu[]; diff --git a/src/utils/providerKeys.ts b/src/utils/providerKeys.ts index 7dfaf3a9..d2d795de 100644 --- a/src/utils/providerKeys.ts +++ b/src/utils/providerKeys.ts @@ -5,7 +5,15 @@ const OAUTH_PROVIDER_ALIASES: Record = { 'x.ai': 'xai', }; +const MANAGEMENT_OAUTH_PROVIDER_PATTERN = /^[a-z0-9-]+$/; + export const normalizeOAuthProviderKey = (value: string): string => { const key = value.trim().toLowerCase().replace(/_/g, '-'); return OAUTH_PROVIDER_ALIASES[key] ?? key; }; + +export const normalizeManagementOAuthProviderKey = (value: string): string => + value.trim().toLowerCase(); + +export const isManagementOAuthProviderKey = (value: string): boolean => + MANAGEMENT_OAUTH_PROVIDER_PATTERN.test(value);