feat: add multi-provider OAuth support for Claude and Gemini CLI
This commit is contained in:
+15
-2
@@ -252,6 +252,10 @@ export type OAuthProviderInfo = {
|
||||
platform: string;
|
||||
enabled: boolean;
|
||||
loginType: 'oauth';
|
||||
requiresProjectId: boolean;
|
||||
supportsDirectAccountRouting: boolean;
|
||||
supportsCloudValidation: boolean;
|
||||
supportsNativeProxy: boolean;
|
||||
};
|
||||
|
||||
export type OAuthSessionInfo = {
|
||||
@@ -271,6 +275,7 @@ export type OAuthConnectionInfo = {
|
||||
email?: string | null;
|
||||
accountKey?: string | null;
|
||||
planType?: string | null;
|
||||
projectId?: string | null;
|
||||
modelCount: number;
|
||||
modelsPreview: string[];
|
||||
status: 'healthy' | 'abnormal';
|
||||
@@ -280,6 +285,13 @@ export type OAuthConnectionInfo = {
|
||||
site?: { id: number; name: string; url: string; platform: string } | null;
|
||||
};
|
||||
|
||||
export type OAuthConnectionsResponse = {
|
||||
items: OAuthConnectionInfo[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: () => request('/api/sites'),
|
||||
@@ -393,12 +405,13 @@ export const api = {
|
||||
|
||||
// OAuth
|
||||
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
|
||||
startOAuthProvider: (provider: string, data?: { accountId?: number }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
|
||||
startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
||||
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
|
||||
getOAuthConnections: () => request('/api/oauth/connections') as Promise<{ items: OAuthConnectionInfo[] }>,
|
||||
getOAuthConnections: (params?: { limit?: number; offset?: number }) =>
|
||||
request(`/api/oauth/connections${buildQueryString(params)}`) as Promise<OAuthConnectionsResponse>,
|
||||
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ? '启动中...' : '重新授权'}
|
||||
|
||||
Reference in New Issue
Block a user