feat: add multi-provider OAuth support for Claude and Gemini CLI

This commit is contained in:
ciat-777
2026-03-17 13:36:44 +08:00
parent 4e3dc92083
commit d8e44eb805
53 changed files with 3618 additions and 719 deletions
+106 -28
View File
@@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import OAuthManagement from './OAuthManagement.js';
const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(() => ({
apiMock: {
getOAuthProviders: vi.fn(),
getOAuthConnections: vi.fn(),
@@ -16,6 +16,7 @@ const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
openMock: vi.fn(),
focusMock: vi.fn(),
confirmMock: vi.fn(),
promptMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
@@ -41,11 +42,14 @@ describe('OAuthManagement page', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
Object.values(apiMock).forEach((mock) => mock.mockReset());
openMock.mockReturnValue({ focus: focusMock });
confirmMock.mockReturnValue(true);
promptMock.mockReturnValue('project-demo');
vi.stubGlobal('window', {
open: openMock,
confirm: confirmMock,
prompt: promptMock,
setTimeout,
clearTimeout,
} as unknown as Window & typeof globalThis);
@@ -60,7 +64,28 @@ describe('OAuthManagement page', () => {
it('renders available oauth providers and existing oauth connections', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
@@ -72,9 +97,12 @@ describe('OAuthManagement page', () => {
planType: 'plus',
modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
status: 'active',
status: 'healthy',
},
],
total: 1,
limit: 100,
offset: 0,
});
let root: ReturnType<typeof create> | null = null;
@@ -88,14 +116,16 @@ describe('OAuthManagement page', () => {
</ToastProvider>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('OAuth 管理');
expect(text).toContain('Codex');
expect(text).toContain('codex-user@example.com');
expect(text).toContain('plus');
expect(text).toContain('3 个模型');
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('OAuth 管理');
expect(text).toContain('Codex');
expect(text).toContain('Gemini CLI');
expect(text).toContain('codex-user@example.com');
expect(text).toContain('plus');
expect(text).toContain('3 个模型');
});
} finally {
root?.unmount();
}
@@ -104,11 +134,32 @@ describe('OAuthManagement page', () => {
it('starts oauth, opens popup, polls status, and refreshes connection list after success', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 })
.mockResolvedValueOnce({
items: [
{
@@ -118,9 +169,12 @@ describe('OAuthManagement page', () => {
planType: 'plus',
modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
status: 'active',
status: 'healthy',
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.startOAuthProvider.mockResolvedValue({
provider: 'codex',
@@ -151,7 +205,9 @@ describe('OAuthManagement page', () => {
</ToastProvider>,
);
});
await flushMicrotasks();
await vi.waitFor(async () => {
await flushMicrotasks();
});
const startButton = root!.root.find((node) => (
node.type === 'button'
@@ -162,9 +218,11 @@ describe('OAuthManagement page', () => {
await act(async () => {
await startButton.props.onClick();
});
await flushMicrotasks();
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex');
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { projectId: undefined });
expect(openMock).toHaveBeenCalledWith(
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
'oauth-codex',
@@ -174,12 +232,16 @@ describe('OAuthManagement page', () => {
await act(async () => {
vi.advanceTimersByTime(1600);
});
await flushMicrotasks();
await vi.waitFor(async () => {
await flushMicrotasks();
});
await act(async () => {
vi.advanceTimersByTime(1600);
});
await flushMicrotasks();
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123');
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
@@ -194,7 +256,17 @@ describe('OAuthManagement page', () => {
it('shows oauth connection status metadata and allows deleting a connection', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
{
provider: 'codex',
label: 'Codex',
platform: 'codex',
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections
@@ -213,8 +285,11 @@ describe('OAuthManagement page', () => {
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
},
],
total: 1,
limit: 100,
offset: 0,
})
.mockResolvedValueOnce({ items: [] });
.mockResolvedValueOnce({ items: [], total: 0, limit: 100, offset: 0 });
apiMock.deleteOAuthConnection.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
@@ -228,12 +303,13 @@ describe('OAuthManagement page', () => {
</ToastProvider>,
);
});
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('异常');
expect(text).toContain('1 条路由');
expect(text).toContain('Codex 模型获取失败');
await vi.waitFor(async () => {
await flushMicrotasks();
const text = collectText(root!.root);
expect(text).toContain('异常');
expect(text).toContain('1 条路由');
expect(text).toContain('Codex 模型获取失败');
});
const deleteButton = root!.root.find((node) => (
node.type === 'button'
@@ -244,7 +320,9 @@ describe('OAuthManagement page', () => {
await act(async () => {
await deleteButton.props.onClick();
});
await flushMicrotasks();
await vi.waitFor(async () => {
await flushMicrotasks();
});
expect(confirmMock).toHaveBeenCalled();
expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7);
+57 -10
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js';
const POLL_INTERVAL_MS = 1500;
const CONNECTION_PAGE_LIMIT = 100;
type ActiveSession = {
provider: string;
@@ -13,8 +14,15 @@ function openOAuthPopup(provider: string, authorizationUrl: string) {
const popup = window.open(
authorizationUrl,
`oauth-${provider}`,
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes',
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes,noopener,noreferrer',
);
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-window opener hardening failures.
}
}
if (popup && typeof popup.focus === 'function') {
popup.focus();
}
@@ -33,7 +41,10 @@ export default function OAuthManagement() {
const [actionLoadingKey, setActionLoadingKey] = useState('');
const loadConnections = async () => {
const response = await api.getOAuthConnections();
const response = await api.getOAuthConnections({
limit: CONNECTION_PAGE_LIMIT,
offset: 0,
});
setConnections(Array.isArray(response?.items) ? response.items : []);
};
@@ -44,6 +55,9 @@ export default function OAuthManagement() {
loadConnections(),
]);
setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []);
} catch (error) {
console.error('failed to load oauth management data', error);
setSessionMessage('OAuth 管理数据加载失败');
} finally {
setLoaded(true);
}
@@ -93,19 +107,30 @@ export default function OAuthManagement() {
};
}, [activeSession]);
const handleStart = async (provider: string, accountId?: number) => {
const actionKey = `start:${provider}:${accountId || 0}`;
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
const actionKey = `start:${provider.provider}:${accountId || 0}`;
setActionLoadingKey(actionKey);
try {
const projectId = provider.requiresProjectId
? (() => {
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined;
const value = window.prompt('输入 Google Cloud Project ID');
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
})()
: undefined;
if (provider.requiresProjectId && !projectId && !accountId) {
setSessionMessage('Gemini CLI 连接需要 Project ID');
return;
}
const started = accountId
? await api.rebindOAuthConnection(accountId)
: await api.startOAuthProvider(provider);
: await api.startOAuthProvider(provider.provider, { projectId });
setSessionMessage('等待授权完成');
setActiveSession({
provider: started.provider,
state: started.state,
});
openOAuthPopup(provider, started.authorizationUrl);
openOAuthPopup(provider.provider, started.authorizationUrl);
} catch (error: any) {
setSessionMessage(error?.message || '无法启动 OAuth 授权');
} finally {
@@ -136,7 +161,7 @@ export default function OAuthManagement() {
<div className="page-header">
<div>
<div className="page-title">OAuth 管理</div>
<div className="page-subtitle">统一管理需要浏览器授权的上游连接。首版支持 Codex。</div>
<div className="page-subtitle">统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。</div>
</div>
</div>
@@ -155,12 +180,16 @@ export default function OAuthManagement() {
<div key={provider.provider} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 14, fontWeight: 600 }}>{provider.label}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{provider.platform}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
{provider.platform}
{provider.requiresProjectId ? ' · 需要 Project ID' : ''}
{provider.supportsNativeProxy ? ' · 原生代理' : ''}
</div>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => handleStart(provider.provider)}
onClick={() => handleStart(provider)}
disabled={actionLoadingKey === actionKey}
>
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
@@ -188,6 +217,11 @@ export default function OAuthManagement() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
</div>
{connection.projectId && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
Project: {connection.projectId}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
</div>
@@ -211,7 +245,20 @@ export default function OAuthManagement() {
<button
type="button"
className="btn btn-ghost"
onClick={() => handleStart(connection.provider, connection.accountId)}
onClick={() => handleStart(
providers.find((provider) => provider.provider === connection.provider) || {
provider: connection.provider,
label: connection.provider,
platform: connection.site?.platform || connection.provider,
enabled: true,
loginType: 'oauth',
requiresProjectId: false,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: false,
},
connection.accountId,
)}
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
>
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}