feat: add plugin OAuth support with UI enhancements and localization updates

This commit is contained in:
Supra4E8C
2026-06-24 21:23:57 +08:00
parent e7e70cc132
commit e3ca3aa4f6
11 changed files with 356 additions and 112 deletions
+10
View File
@@ -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.",
+10
View File
@@ -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?...",
+10
View File
@@ -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?... 后提交。",
+10
View File
@@ -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?... 後提交。",
+9
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
}
},
};
+22 -6
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -39,6 +39,7 @@ export interface PluginListEntry {
enabled: boolean;
effectiveEnabled: boolean;
supportsOAuth: boolean;
oauthProvider?: string;
logo: string;
configFields: PluginConfigField[];
menus: PluginMenu[];
+8
View File
@@ -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);