Add OAuth account proxy controls (#307)

* Add oauth account proxy controls

* Refine oauth proxy review fixes

* Fix oauth clear-proxy exchange fallback
This commit is contained in:
Cita
2026-03-29 00:35:45 +08:00
committed by GitHub
parent 785ee93505
commit 03ca1154a0
9 changed files with 584 additions and 21 deletions
+4 -3
View File
@@ -497,6 +497,7 @@ export type OAuthConnectionInfo = {
routeChannelCount?: number;
lastModelSyncAt?: string | null;
lastModelSyncError?: string | null;
proxyUrl?: string | null;
site?: { id: number; name: string; url: string; platform: string } | null;
};
@@ -625,7 +626,7 @@ export const api = {
// OAuth
getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>,
startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string; proxyUrl?: string | null }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, {
method: 'POST',
body: JSON.stringify(data || {}),
}) as Promise<OAuthStartResponse>,
@@ -640,9 +641,9 @@ export const api = {
method: 'POST',
body: JSON.stringify({}),
}) as Promise<{ success: true; quota: OAuthQuotaInfo }>,
rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, {
rebindOAuthConnection: (accountId: number, data?: { proxyUrl?: string | null }) => request(`/api/oauth/connections/${accountId}/rebind`, {
method: 'POST',
body: JSON.stringify({}),
body: JSON.stringify(data || {}),
}) as Promise<OAuthStartResponse>,
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
method: 'DELETE',
+201 -2
View File
@@ -221,6 +221,25 @@ describe('OAuthManagement page', () => {
await flushMicrotasks();
});
const proxyToggle = root!.root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props['data-oauth-setting'] === 'use-proxy'
));
await act(async () => {
proxyToggle.props.onChange({ target: { checked: true } });
});
const proxyInput = root!.root.find((node) => (
node.type === 'input'
&& node.props['data-oauth-setting'] === 'proxy-url'
));
await act(async () => {
proxyInput.props.onChange({ target: { value: 'http://127.0.0.1:7890' } });
});
const startButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
@@ -234,7 +253,10 @@ describe('OAuthManagement page', () => {
await flushMicrotasks();
});
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', { projectId: undefined });
expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('codex', {
projectId: undefined,
proxyUrl: 'http://127.0.0.1:7890',
});
expect(openMock).toHaveBeenCalledWith(
'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
'oauth-codex',
@@ -647,7 +669,7 @@ describe('OAuthManagement page', () => {
});
expect(promptMock).not.toHaveBeenCalled();
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11);
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, {});
expect(openMock).toHaveBeenCalledWith(
'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123',
'oauth-gemini-cli',
@@ -658,6 +680,180 @@ describe('OAuthManagement page', () => {
}
});
it('reuses the stored account proxy for rebind when no new override is selected', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 11,
provider: 'gemini-cli',
email: 'gemini@example.com',
projectId: 'project-demo',
modelCount: 5,
modelsPreview: ['gemini-2.5-pro'],
status: 'healthy',
proxyUrl: 'http://127.0.0.1:7890',
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.rebindOAuthConnection.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-proxy',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-proxy',
instructions: {
redirectUri: 'http://localhost:8085/oauth2callback',
callbackPort: 8085,
callbackPath: '/oauth2callback',
manualCallbackDelayMs: 15000,
},
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-proxy',
status: 'pending',
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const rebindButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('重新授权')
));
await act(async () => {
await rebindButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, {
proxyUrl: 'http://127.0.0.1:7890',
});
} finally {
root?.unmount();
}
});
it('can clear the stored account proxy during rebind', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
{
provider: 'gemini-cli',
label: 'Gemini CLI',
platform: 'gemini-cli',
enabled: true,
loginType: 'oauth',
requiresProjectId: true,
supportsDirectAccountRouting: true,
supportsCloudValidation: true,
supportsNativeProxy: true,
},
],
});
apiMock.getOAuthConnections.mockResolvedValue({
items: [
{
accountId: 11,
provider: 'gemini-cli',
email: 'gemini@example.com',
projectId: 'project-demo',
modelCount: 5,
modelsPreview: ['gemini-2.5-pro'],
status: 'healthy',
proxyUrl: 'http://127.0.0.1:7890',
},
],
total: 1,
limit: 100,
offset: 0,
});
apiMock.rebindOAuthConnection.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-clear',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-clear',
instructions: {
redirectUri: 'http://localhost:8085/oauth2callback',
callbackPort: 8085,
callbackPath: '/oauth2callback',
manualCallbackDelayMs: 15000,
},
});
apiMock.getOAuthSession.mockResolvedValue({
provider: 'gemini-cli',
state: 'gemini-rebind-clear',
status: 'pending',
});
let root!: WebTestRenderer;
try {
await act(async () => {
root = create(
<ToastProvider>
<MemoryRouter>
<OAuthManagement />
</MemoryRouter>
</ToastProvider>,
);
});
await flushMicrotasks();
const clearProxyToggle = root!.root.find((node) => (
node.type === 'input'
&& node.props.type === 'checkbox'
&& node.props['data-oauth-setting'] === 'clear-proxy'
));
await act(async () => {
clearProxyToggle.props.onChange({ target: { checked: true } });
});
const rebindButton = root!.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('重新授权')
));
await act(async () => {
await rebindButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11, {
proxyUrl: null,
});
} finally {
root?.unmount();
}
});
it('shows oauth connection status metadata and allows deleting a connection', async () => {
apiMock.getOAuthProviders.mockResolvedValue({
providers: [
@@ -708,6 +904,7 @@ describe('OAuthManagement page', () => {
routeChannelCount: 1,
lastModelSyncAt: '2026-03-17T08:00:00.000Z',
lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)',
proxyUrl: 'http://oauth-user:secret@127.0.0.1:7890',
},
],
total: 1,
@@ -740,6 +937,8 @@ describe('OAuthManagement page', () => {
expect(text).toContain('2026-04-01T00:00:00.000Z');
expect(text).toContain('2026-03-17T13:00:00.000Z');
expect(text).toContain('官方未提供');
expect(text).toContain('http://***@127.0.0.1:7890');
expect(text).not.toContain('oauth-user:secret');
});
const deleteButton = root!.root.find((node) => (
+140 -4
View File
@@ -45,6 +45,24 @@ function asTrimmedString(value: string | null | undefined): string {
return typeof value === 'string' ? value.trim() : '';
}
function redactProxyUrl(value: string | null | undefined): string {
const text = asTrimmedString(value);
if (!text) return '';
try {
const parsed = new URL(text);
if (parsed.username || parsed.password) {
parsed.username = '***';
parsed.password = '';
}
const serialized = parsed.toString();
return parsed.pathname === '/' && !parsed.search && !parsed.hash
? serialized.replace(/\/$/, '')
: serialized;
} catch {
return text.replace(/\/\/[^/@:\s]+(?::[^/@\s]*)?@/, '//***@');
}
}
function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string {
if (loading) return '启动中...';
if (!provider.enabled) return '当前不可用';
@@ -143,6 +161,9 @@ export default function OAuthManagement() {
const [manualCallbackVisible, setManualCallbackVisible] = useState(false);
const [manualCallbackUrl, setManualCallbackUrl] = useState('');
const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false);
const [oauthProxyEnabled, setOauthProxyEnabled] = useState(false);
const [oauthProxyClearEnabled, setOauthProxyClearEnabled] = useState(false);
const [oauthProxyUrl, setOauthProxyUrl] = useState('');
const loadConnections = async () => {
const response = await api.getOAuthConnections({
@@ -230,11 +251,31 @@ export default function OAuthManagement() {
return () => clearTimeout(timer);
}, [activeSession]);
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
const handleStart = async (
provider: OAuthProviderInfo,
options?: {
accountId?: number;
fallbackProxyUrl?: string | null;
},
) => {
if (!provider.enabled) {
setSessionMessage(`${provider.label} 当前环境未启用`);
return;
}
const accountId = options?.accountId;
const customProxyUrl = asTrimmedString(oauthProxyUrl);
if (oauthProxyEnabled && !customProxyUrl) {
setSessionMessage('已开启代理,请先输入完整代理地址');
return;
}
let resolvedProxyUrl: string | null | undefined;
if (oauthProxyEnabled) {
resolvedProxyUrl = customProxyUrl;
} else if (oauthProxyClearEnabled) {
resolvedProxyUrl = null;
} else if (options?.fallbackProxyUrl !== undefined) {
resolvedProxyUrl = asTrimmedString(options.fallbackProxyUrl) || null;
}
const actionKey = `start:${provider.provider}:${accountId || 0}`;
setActionLoadingKey(actionKey);
try {
@@ -247,8 +288,14 @@ export default function OAuthManagement() {
})()
: undefined;
const started = accountId
? await api.rebindOAuthConnection(accountId)
: await api.startOAuthProvider(provider.provider, { projectId });
? await api.rebindOAuthConnection(
accountId,
resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {},
)
: await api.startOAuthProvider(provider.provider, {
projectId,
...(resolvedProxyUrl !== undefined ? { proxyUrl: resolvedProxyUrl } : {}),
});
setSessionMessage('等待授权完成');
setActiveSession({
provider: started.provider,
@@ -325,6 +372,87 @@ export default function OAuthManagement() {
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16, display: 'grid', gap: 12 }}>
<div style={{ fontSize: 15, fontWeight: 600 }}>授权设置</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
这里的设置会作用于下一次“连接”或“重新授权”。填写代理地址后,本次 OAuth 换 token 和后续生成的账号都会直接带上这份账号级代理配置。
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 14px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
}}
>
<input
type="checkbox"
checked={oauthProxyEnabled}
data-oauth-setting="use-proxy"
onChange={(event) => {
const checked = !!event.target.checked;
setOauthProxyEnabled(checked);
if (checked) setOauthProxyClearEnabled(false);
}}
/>
<span style={{ fontSize: 13, color: 'var(--color-text-primary)' }}>使用自定义代理</span>
</label>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 14px',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
}}
>
<input
type="checkbox"
checked={oauthProxyClearEnabled}
data-oauth-setting="clear-proxy"
onChange={(event) => {
const checked = !!event.target.checked;
setOauthProxyClearEnabled(checked);
if (checked) setOauthProxyEnabled(false);
}}
/>
<span style={{ fontSize: 13, color: 'var(--color-text-primary)' }}>清空已有账号代理</span>
</label>
</div>
<div style={{ display: 'grid', gap: 6 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>代理地址</div>
<input
type="text"
value={oauthProxyUrl}
data-oauth-setting="proxy-url"
onChange={(event) => setOauthProxyUrl(event.target.value)}
placeholder="如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080"
disabled={!oauthProxyEnabled}
style={{
width: '100%',
maxWidth: 460,
padding: '10px 14px',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
background: oauthProxyEnabled ? 'var(--color-bg)' : 'var(--color-bg-card)',
color: 'var(--color-text-primary)',
fontSize: 13,
opacity: oauthProxyEnabled ? 1 : 0.7,
}}
/>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
关闭时,新建连接会按站点/系统默认链路处理;重新授权已有账号时,会继续沿用该账号已有的代理配置。
勾选“清空已有账号代理”后,重新授权会显式移除当前账号上的代理覆盖。
</div>
</div>
</div>
{sessionMessage && (
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{sessionMessage}</div>
@@ -494,6 +622,11 @@ export default function OAuthManagement() {
Project: {connection.projectId}
</div>
)}
{connection.proxyUrl && (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
账号代理: {redactProxyUrl(connection.proxyUrl)}
</div>
)}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
{resolveConnectionStatusLabel(connection.status)} · {connection.routeChannelCount || 0} 条路由
</div>
@@ -593,7 +726,10 @@ export default function OAuthManagement() {
supportsCloudValidation: true,
supportsNativeProxy: false,
},
connection.accountId,
{
accountId: connection.accountId,
fallbackProxyUrl: connection.proxyUrl,
},
)}
disabled={
actionLoadingKey === rebindActionKey