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:
@@ -21,4 +21,12 @@ describe('App sidebar config', () => {
|
||||
expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex);
|
||||
expect(systemGroupIndex).toBeGreaterThan(downstreamIndex);
|
||||
});
|
||||
|
||||
it('adds standalone OAuth 管理 navigation entry', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8');
|
||||
|
||||
expect(source).toContain("{ to: '/oauth', label: 'OAuth 管理'");
|
||||
expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));");
|
||||
expect(source).toContain('<Route path="/oauth" element={<OAuthManagement />} />');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ const Models = lazy(() => import('./pages/Models.js'));
|
||||
const About = lazy(() => import('./pages/About.js'));
|
||||
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
|
||||
const Monitors = lazy(() => import('./pages/Monitors.js'));
|
||||
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
|
||||
|
||||
type ThemeMode = 'system' | 'light' | 'dark';
|
||||
|
||||
@@ -328,6 +329,7 @@ const sidebarGroups = [
|
||||
{ to: '/', label: '仪表盘', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v5a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v2a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 12a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1v-7z" /></svg> },
|
||||
{ to: '/sites', label: '站点', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg> },
|
||||
{ to: '/accounts', label: '连接管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg> },
|
||||
{ to: '/oauth', label: 'OAuth 管理', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a3 3 0 106 0 3 3 0 00-6 0zM3 17a3 3 0 106 0 3 3 0 00-6 0zM15 17a3 3 0 106 0 3 3 0 00-6 0zM6 14V10m0 0a3 3 0 113-3m-3 3a3 3 0 003 3h6" /></svg> },
|
||||
{ to: '/downstream-keys', label: '下游密钥', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M15 7a4 4 0 11-8 0 4 4 0 018 0z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 21a6 6 0 0110.8-3.6M15.5 18.5l2-2m0 0l2 2m-2-2V21" /></svg> },
|
||||
{ to: '/checkin', label: '签到记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
|
||||
{ to: '/routes', label: '路由', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
|
||||
@@ -763,6 +765,7 @@ function AppShell() {
|
||||
<Route path="/" element={<Dashboard adminName={displayName} />} />
|
||||
<Route path="/sites" element={<Sites />} />
|
||||
<Route path="/accounts" element={<Accounts />} />
|
||||
<Route path="/oauth" element={<OAuthManagement />} />
|
||||
<Route path="/tokens" element={<Tokens />} />
|
||||
<Route path="/checkin" element={<CheckinLog />} />
|
||||
<Route path="/routes" element={<TokenRoutes />} />
|
||||
|
||||
@@ -246,6 +246,40 @@ export type ProxyLogsResponse = {
|
||||
summary: ProxyLogsSummary;
|
||||
};
|
||||
|
||||
export type OAuthProviderInfo = {
|
||||
provider: string;
|
||||
label: string;
|
||||
platform: string;
|
||||
enabled: boolean;
|
||||
loginType: 'oauth';
|
||||
};
|
||||
|
||||
export type OAuthSessionInfo = {
|
||||
provider: string;
|
||||
state: string;
|
||||
status: 'pending' | 'success' | 'error';
|
||||
accountId?: number;
|
||||
siteId?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type OAuthConnectionInfo = {
|
||||
accountId: number;
|
||||
siteId: number;
|
||||
provider: string;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
accountKey?: string | null;
|
||||
planType?: string | null;
|
||||
modelCount: number;
|
||||
modelsPreview: string[];
|
||||
status: 'healthy' | 'abnormal';
|
||||
routeChannelCount?: number;
|
||||
lastModelSyncAt?: string | null;
|
||||
lastModelSyncError?: string | null;
|
||||
site?: { id: number; name: string; url: string; platform: string } | null;
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Sites
|
||||
getSites: () => request('/api/sites'),
|
||||
@@ -357,6 +391,22 @@ export const api = {
|
||||
// Search
|
||||
search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }),
|
||||
|
||||
// OAuth
|
||||
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
|
||||
startOAuthProvider: (provider: string, data?: { accountId?: number }) => 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[] }>,
|
||||
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
||||
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
|
||||
method: 'DELETE',
|
||||
}) as Promise<{ success: true }>,
|
||||
|
||||
// Events
|
||||
getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`),
|
||||
getEventCount: () => request('/api/events/count'),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('编辑群组');
|
||||
|
||||
Reference in New Issue
Block a user