mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-01 15:34:05 +08:00
feat: add plugin OAuth support with UI enhancements and localization updates
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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?...",
|
||||
|
||||
@@ -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?... 后提交。",
|
||||
|
||||
@@ -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?... 後提交。",
|
||||
|
||||
@@ -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;
|
||||
|
||||
+244
-79
@@ -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<string>(PROVIDERS.map((provider) => provider.id));
|
||||
const CALLBACK_SUPPORTED = new Set<string>(['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 (
|
||||
<img src={src} alt="" className={styles.cardTitleIcon} onError={() => setFailed(true)} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles.cardTitleIconFallback} aria-hidden="true">
|
||||
<IconPlug size={18} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function OAuthProviderIcon({
|
||||
provider,
|
||||
theme,
|
||||
}: {
|
||||
provider: OAuthProviderCard;
|
||||
theme: 'light' | 'dark';
|
||||
}) {
|
||||
if (provider.kind === 'plugin') {
|
||||
return <PluginOAuthIcon src={provider.icon} />;
|
||||
}
|
||||
return <img src={getIcon(provider.icon, theme)} alt="" className={styles.cardTitleIcon} />;
|
||||
}
|
||||
|
||||
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<Record<OAuthProvider, ProviderState>>({} as Record<OAuthProvider, ProviderState>);
|
||||
const [states, setStates] = useState<Record<string, ProviderState>>({});
|
||||
const [pluginProviders, setPluginProviders] = useState<PluginOAuthProviderCard[]>([]);
|
||||
const [vertexState, setVertexState] = useState<VertexImportState>({
|
||||
fileName: '',
|
||||
location: '',
|
||||
loading: false
|
||||
loading: false,
|
||||
});
|
||||
const pollingTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
|
||||
const successResetTimers = useRef<Partial<Record<OAuthProvider, number>>>({});
|
||||
const pollingTimers = useRef<Partial<Record<string, number>>>({});
|
||||
const successResetTimers = useRef<Partial<Record<string, number>>>({});
|
||||
const vertexFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
@@ -179,14 +283,57 @@ export function OAuthPage() {
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
const updateProviderState = (provider: OAuthProvider, next: Partial<ProviderState>) => {
|
||||
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<OAuthProviderCard[]>(
|
||||
() => [...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<ProviderState>) => {
|
||||
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() {
|
||||
<h1 className={styles.pageTitle}>{t('nav.oauth', { defaultValue: 'OAuth' })}</h1>
|
||||
|
||||
<div className={styles.content}>
|
||||
{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() {
|
||||
<Card
|
||||
title={
|
||||
<span className={styles.cardTitle}>
|
||||
<img
|
||||
src={getIcon(provider.icon, resolvedTheme)}
|
||||
alt=""
|
||||
className={styles.cardTitleIcon}
|
||||
/>
|
||||
{t(provider.titleKey)}
|
||||
<OAuthProviderIcon provider={provider} theme={resolvedTheme} />
|
||||
{getProviderTitleText(provider)}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
@@ -450,21 +613,23 @@ export function OAuthPage() {
|
||||
}
|
||||
>
|
||||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHint}>{t(provider.hintKey)}</div>
|
||||
<div className={styles.cardHint}>{getProviderText(provider, 'oauth_hint')}</div>
|
||||
{state.url && (
|
||||
<div className={styles.authUrlBox}>
|
||||
<div className={styles.authUrlLabel}>{t(provider.urlLabelKey)}</div>
|
||||
<div className={styles.authUrlLabel}>
|
||||
{getProviderText(provider, 'oauth_url_label')}
|
||||
</div>
|
||||
<div className={styles.authUrlValue}>{state.url}</div>
|
||||
<div className={styles.authUrlActions}>
|
||||
<Button variant="secondary" size="sm" onClick={() => copyLink(state.url!)}>
|
||||
{t(getAuthKey(provider.id, 'copy_link'))}
|
||||
{getProviderText(provider, 'copy_link')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open(state.url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
{t(getAuthKey(provider.id, 'open_link'))}
|
||||
{getProviderText(provider, 'open_link')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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' && (
|
||||
<div className={statusBadgeClassName}>
|
||||
{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')}
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
</div>
|
||||
{vertexState.error && (
|
||||
<div className="status-badge error">
|
||||
{vertexState.error}
|
||||
</div>
|
||||
)}
|
||||
{vertexState.error && <div className="status-badge error">{vertexState.error}</div>}
|
||||
{vertexState.result && (
|
||||
<div className={styles.connectionBox}>
|
||||
<div className={styles.connectionLabel}>{t('vertex_import.result_title')}</div>
|
||||
<div className={styles.keyValueList}>
|
||||
{vertexState.result.projectId && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('vertex_import.result_project')}</span>
|
||||
<span className={styles.keyValueKey}>
|
||||
{t('vertex_import.result_project')}
|
||||
</span>
|
||||
<span className={styles.keyValueValue}>{vertexState.result.projectId}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -614,7 +777,9 @@ export function OAuthPage() {
|
||||
)}
|
||||
{vertexState.result.location && (
|
||||
<div className={styles.keyValueItem}>
|
||||
<span className={styles.keyValueKey}>{t('vertex_import.result_location')}</span>
|
||||
<span className={styles.keyValueKey}>
|
||||
{t('vertex_import.result_location')}
|
||||
</span>
|
||||
<span className={styles.keyValueValue}>{vertexState.result.location}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+27
-21
@@ -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<string>(['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<string, string | boolean> = {};
|
||||
if (WEBUI_SUPPORTED.includes(provider)) {
|
||||
if (WEBUI_SUPPORTED.has(providerKey)) {
|
||||
params.is_webui = true;
|
||||
}
|
||||
return apiClient.get<OAuthStartResponse>(`/${provider}-auth-url`, {
|
||||
params: Object.keys(params).length ? params : undefined
|
||||
return apiClient.get<OAuthStartResponse>(`/${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<OAuthCallbackResponse>('/oauth-callback', {
|
||||
provider,
|
||||
redirect_url: redirectUrl
|
||||
provider: providerKey,
|
||||
redirect_url: redirectUrl,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+5
-6
@@ -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 {
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface PluginListEntry {
|
||||
enabled: boolean;
|
||||
effectiveEnabled: boolean;
|
||||
supportsOAuth: boolean;
|
||||
oauthProvider?: string;
|
||||
logo: string;
|
||||
configFields: PluginConfigField[];
|
||||
menus: PluginMenu[];
|
||||
|
||||
@@ -5,7 +5,15 @@ const OAUTH_PROVIDER_ALIASES: Record<string, string> = {
|
||||
'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);
|
||||
|
||||
Reference in New Issue
Block a user