feat: implement Codex OAuth flow with loopback callback server and management UI

- Add localCallbackServer for handling OAuth callback requests.
- Implement service functions for managing OAuth sessions and accounts.
- Create CodexAdapter for platform-specific interactions.
- Develop OAuthManagement page for managing OAuth connections and providers.
- Add tests for localCallbackServer and OAuthManagement components.
This commit is contained in:
ciat-777
2026-03-17 11:07:25 +08:00
parent c156e0c164
commit 575a15232b
40 changed files with 3219 additions and 32 deletions
+256
View File
@@ -0,0 +1,256 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import OAuthManagement from './OAuthManagement.js';
const { apiMock, openMock, focusMock, confirmMock } = vi.hoisted(() => ({
apiMock: {
getOAuthProviders: vi.fn(),
getOAuthConnections: vi.fn(),
startOAuthProvider: vi.fn(),
getOAuthSession: vi.fn(),
rebindOAuthConnection: vi.fn(),
deleteOAuthConnection: vi.fn(),
},
openMock: vi.fn(),
focusMock: vi.fn(),
confirmMock: vi.fn(),
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: any): string {
const children = node?.children || [];
return children.map((child: any) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('OAuthManagement page', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
openMock.mockReturnValue({ focus: focusMock });
confirmMock.mockReturnValue(true);
vi.stubGlobal('window', {
open: openMock,
confirm: confirmMock,
setTimeout,
clearTimeout,
} as unknown as Window & typeof globalThis);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('renders available oauth providers and existing oauth connections', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{ provider: 'codex', label: 'Codex', platform: 'codex', enabled: true, loginType: 'oauth' },
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
planType: 'plus',
modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
status: 'active',
},
],
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</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 个模型');
} finally {
root?.unmount();
}
});
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' },
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({ items: [] })
.mockResolvedValueOnce({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
planType: 'plus',
modelCount: 3,
modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'],
status: 'active',
},
],
});
apiMock.startOAuthProvider.mockResolvedValue({
provider: 'codex',
state: 'oauth-state-123',
authorizationUrl: 'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
});
apiMock.getOAuthSession
.mockResolvedValueOnce({
provider: 'codex',
state: 'oauth-state-123',
status: 'pending',
})
.mockResolvedValueOnce({
provider: 'codex',
state: 'oauth-state-123',
status: 'success',
accountId: 7,
});
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const startButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('连接 Codex')
));
await act(async () => {
await startButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex');
expect(openMock).toHaveBeenCalledWith(
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
'oauth-codex',
expect.stringContaining('width=540'),
);
await act(async () => {
vi.advanceTimersByTime(1600);
});
await flushMicrotasks();
await act(async () => {
vi.advanceTimersByTime(1600);
});
await flushMicrotasks();
expect(apiMock.getOAuthSession).toHaveBeenCalledWith('oauth-state-123');
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
const text = collectText(root!.root);
expect(text).toContain('授权成功');
expect(text).toContain('codex-user@example.com');
} finally {
root?.unmount();
}
});
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' },
],
});
apiMock.getOAuthConnections
.mockResolvedValueOnce({
items: [
{
accountId: 7,
provider: 'codex',
email: 'codex-user@example.com',
planType: 'team',
modelCount: 11,
modelsPreview: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex'],
status: 'abnormal',
routeChannelCount: 1,
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
},
],
})
.mockResolvedValueOnce({ items: [] });
apiMock.deleteOAuthConnection.mockResolvedValue({ success: true });
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
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'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('删除连接')
));
await act(async () => {
await deleteButton.props.onClick();
});
await flushMicrotasks();
expect(confirmMock).toHaveBeenCalled();
expect(apiMock.deleteOAuthConnection).toHaveBeenCalledWith(7);
expect(apiMock.getOAuthConnections).toHaveBeenCalledTimes(2);
} finally {
root?.unmount();
}
});
});
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useState } from 'react';
import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js';
const POLL_INTERVAL_MS = 1500;
type ActiveSession = {
provider: string;
state: string;
};
function openOAuthPopup(provider: string, authorizationUrl: string) {
if (typeof window === 'undefined' || typeof window.open !== 'function') return;
const popup = window.open(
authorizationUrl,
`oauth-${provider}`,
'popup=yes,width=540,height=760,resizable=yes,scrollbars=yes',
);
if (popup && typeof popup.focus === 'function') {
popup.focus();
}
}
function resolveConnectionStatusLabel(status?: string): string {
return status === 'abnormal' ? '异常' : '正常';
}
export default function OAuthManagement() {
const [providers, setProviders] = useState<OAuthProviderInfo[]>([]);
const [connections, setConnections] = useState<OAuthConnectionInfo[]>([]);
const [loaded, setLoaded] = useState(false);
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
const [sessionMessage, setSessionMessage] = useState('');
const [actionLoadingKey, setActionLoadingKey] = useState('');
const loadConnections = async () => {
const response = await api.getOAuthConnections();
setConnections(Array.isArray(response?.items) ? response.items : []);
};
const load = async () => {
try {
const [providersResponse] = await Promise.all([
api.getOAuthProviders(),
loadConnections(),
]);
setProviders(Array.isArray(providersResponse?.providers) ? providersResponse.providers : []);
} finally {
setLoaded(true);
}
};
useEffect(() => {
load();
}, []);
useEffect(() => {
if (!activeSession) return;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
try {
const session = await api.getOAuthSession(activeSession.state);
if (cancelled) return;
if (session.status === 'pending') {
setSessionMessage('等待授权完成');
timer = setTimeout(poll, POLL_INTERVAL_MS);
return;
}
if (session.status === 'success') {
setSessionMessage('授权成功');
await loadConnections();
setActiveSession(null);
return;
}
setSessionMessage(`授权失败:${session.error || '未知错误'}`);
setActiveSession(null);
} catch (error: any) {
if (cancelled) return;
setSessionMessage(error?.message || 'OAuth 会话状态查询失败');
setActiveSession(null);
}
};
poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [activeSession]);
const handleStart = async (provider: string, accountId?: number) => {
const actionKey = `start:${provider}:${accountId || 0}`;
setActionLoadingKey(actionKey);
try {
const started = accountId
? await api.rebindOAuthConnection(accountId)
: await api.startOAuthProvider(provider);
setSessionMessage('等待授权完成');
setActiveSession({
provider: started.provider,
state: started.state,
});
openOAuthPopup(provider, started.authorizationUrl);
} catch (error: any) {
setSessionMessage(error?.message || '无法启动 OAuth 授权');
} finally {
setActionLoadingKey('');
}
};
const handleDelete = async (accountId: number) => {
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?');
if (!confirmed) return;
}
const actionKey = `delete:${accountId}`;
setActionLoadingKey(actionKey);
try {
await api.deleteOAuthConnection(accountId);
setSessionMessage('连接已删除');
await loadConnections();
} catch (error: any) {
setSessionMessage(error?.message || '删除 OAuth 连接失败');
} finally {
setActionLoadingKey('');
}
};
return (
<div className="page-container">
<div className="page-header">
<div>
<div className="page-title">OAuth 管理</div>
<div className="page-subtitle">统一管理需要浏览器授权的上游连接。首版支持 Codex。</div>
</div>
</div>
{sessionMessage && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{sessionMessage}</div>
</div>
)}
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权入口</div>
<div style={{ display: 'grid', gap: 12 }}>
{providers.map((provider) => {
const actionKey = `start:${provider.provider}:0`;
return (
<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>
<button
type="button"
className="btn btn-primary"
onClick={() => handleStart(provider.provider)}
disabled={actionLoadingKey === actionKey}
>
{actionLoadingKey === actionKey ? '启动中...' : `连接 ${provider.label}`}
</button>
</div>
);
})}
{loaded && providers.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>当前没有可用的 OAuth Provider。</div>
)}
</div>
</div>
<div className="card" style={{ padding: 20 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>已连接账号</div>
<div style={{ display: 'grid', gap: 12 }}>
{connections.map((connection) => {
const rebindActionKey = `start:${connection.provider}:${connection.accountId}`;
const deleteActionKey = `delete:${connection.accountId}`;
return (
<div key={connection.accountId} style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 14 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{connection.email || connection.username || connection.provider}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{connection.planType || 'unknown'} · {connection.modelCount} 个模型
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
</div>
{connection.lastModelSyncAt && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
最近同步: {connection.lastModelSyncAt}
</div>
)}
{connection.lastModelSyncError && (
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: 4 }}>
{connection.lastModelSyncError}
</div>
)}
{connection.modelsPreview.length > 0 && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 6 }}>
{connection.modelsPreview.join(', ')}
</div>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
className="btn btn-ghost"
onClick={() => handleStart(connection.provider, connection.accountId)}
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
>
{actionLoadingKey === rebindActionKey ? '启动中...' : '重新授权'}
</button>
<button
type="button"
className="btn btn-ghost"
onClick={() => handleDelete(connection.accountId)}
disabled={actionLoadingKey === rebindActionKey || actionLoadingKey === deleteActionKey}
>
{actionLoadingKey === deleteActionKey ? '删除中...' : '删除连接'}
</button>
</div>
</div>
</div>
);
})}
{loaded && connections.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>还没有 OAuth 连接。</div>
)}
</div>
</div>
</div>
);
}
+8 -1
View File
@@ -52,6 +52,7 @@ const platformColors: Record<string, string> = {
'done-hub': 'badge-muted',
sub2api: 'badge-muted',
openai: 'badge-success',
codex: 'badge-success',
claude: 'badge-warning',
gemini: 'badge-info',
cliproxyapi: 'badge-info',
@@ -67,6 +68,7 @@ const SITE_PLATFORM_OPTIONS = [
{ value: 'done-hub', label: 'done-hub' },
{ value: 'sub2api', label: 'sub2api' },
{ value: 'openai', label: 'openai' },
{ value: 'codex', label: 'codex' },
{ value: 'claude', label: 'claude' },
{ value: 'gemini', label: 'gemini' },
{ value: 'cliproxyapi', label: 'cliproxyapi' },
@@ -290,11 +292,16 @@ export default function Sites() {
const createdPlatform = typeof created?.platform === 'string' && created.platform.trim()
? created.platform.trim()
: payload.platform;
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
const params = new URLSearchParams({
create: '1',
siteId: String(createdSiteId),
});
if (String(createdPlatform || '').trim().toLowerCase() === 'codex') {
params.set('provider', 'codex');
navigate(`/oauth?${params.toString()}`);
return;
}
const initialSegment = resolveInitialConnectionSegment(createdPlatform);
if (initialSegment === 'apikey') {
params.set('segment', 'apikey');
}
@@ -50,6 +50,7 @@ async function createSiteAndCollectLocation(createdSite: { id: number; platform?
<Routes>
<Route path="/sites" element={<Sites />} />
<Route path="/accounts" element={<LocationProbe />} />
<Route path="/oauth" element={<LocationProbe />} />
</Routes>
</MemoryRouter>
</ToastProvider>,
@@ -124,4 +125,13 @@ describe('Sites create redirect', () => {
expect(rendered).toContain('create=1');
expect(rendered).toContain('siteId=22');
});
it('redirects a codex site to oauth management instead of legacy account import', async () => {
const rendered = await createSiteAndCollectLocation({ id: 23, platform: 'codex' });
expect(rendered).toContain('/oauth?');
expect(rendered).toContain('provider=codex');
expect(rendered).toContain('create=1');
expect(rendered).toContain('siteId=23');
});
});
+1 -1
View File
@@ -473,7 +473,7 @@ function RouteCardInner({
</div>
) : (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', paddingLeft: 4 }}>
{readOnlyRoute ? tr('暂无通道,先补齐令牌后再重建路由。') : tr('暂无通道')}
{readOnlyRoute ? tr('暂无通道,先补齐连接配置后再重建路由。') : tr('暂无通道')}
</div>
)}
</div>
@@ -346,7 +346,7 @@ describe('TokenRoutes grouped source models', () => {
const expandedText = collectText(root.root);
expect(expandedText).toContain('待注册站点');
expect(expandedText).toContain('Wong');
expect(expandedText).toContain('暂无通道,先补齐令牌后再重建路由。');
expect(expandedText).toContain('暂无通道,先补齐连接配置后再重建路由。');
expect(expandedText).not.toContain('添加通道');
expect(expandedText).not.toContain('删除路由');
expect(expandedText).not.toContain('编辑群组');