feat: align oauth loopback flow with cliproxyapi
This commit is contained in:
+22
-2
@@ -258,6 +258,22 @@ export type OAuthProviderInfo = {
|
||||
supportsNativeProxy: boolean;
|
||||
};
|
||||
|
||||
export type OAuthStartInstructions = {
|
||||
redirectUri: string;
|
||||
callbackPort: number;
|
||||
callbackPath: string;
|
||||
manualCallbackDelayMs: number;
|
||||
sshTunnelCommand?: string;
|
||||
sshTunnelKeyCommand?: string;
|
||||
};
|
||||
|
||||
export type OAuthStartResponse = {
|
||||
provider: string;
|
||||
state: string;
|
||||
authorizationUrl: string;
|
||||
instructions: OAuthStartInstructions;
|
||||
};
|
||||
|
||||
export type OAuthSessionInfo = {
|
||||
provider: string;
|
||||
state: string;
|
||||
@@ -408,14 +424,18 @@ export const api = {
|
||||
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 }>,
|
||||
}) as Promise<OAuthStartResponse>,
|
||||
getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise<OAuthSessionInfo>,
|
||||
submitOAuthManualCallback: (state: string, callbackUrl: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}/manual-callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ callbackUrl }),
|
||||
}) as Promise<{ success: true }>,
|
||||
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({}),
|
||||
}) as Promise<{ provider: string; state: string; authorizationUrl: string }>,
|
||||
}) as Promise<OAuthStartResponse>,
|
||||
deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, {
|
||||
method: 'DELETE',
|
||||
}) as Promise<{ success: true }>,
|
||||
|
||||
@@ -10,6 +10,7 @@ const { apiMock, openMock, focusMock, confirmMock, promptMock } = vi.hoisted(()
|
||||
getOAuthConnections: vi.fn(),
|
||||
startOAuthProvider: vi.fn(),
|
||||
getOAuthSession: vi.fn(),
|
||||
submitOAuthManualCallback: vi.fn(),
|
||||
rebindOAuthConnection: vi.fn(),
|
||||
deleteOAuthConnection: vi.fn(),
|
||||
},
|
||||
@@ -180,6 +181,13 @@ describe('OAuthManagement page', () => {
|
||||
provider: 'codex',
|
||||
state: 'oauth-state-123',
|
||||
authorizationUrl: 'https://auth.openai.com/oauth/authorize?state=oauth-state-123',
|
||||
instructions: {
|
||||
redirectUri: 'http://localhost:1455/auth/callback',
|
||||
callbackPort: 1455,
|
||||
callbackPath: '/auth/callback',
|
||||
manualCallbackDelayMs: 15000,
|
||||
sshTunnelCommand: 'ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22',
|
||||
},
|
||||
});
|
||||
apiMock.getOAuthSession
|
||||
.mockResolvedValueOnce({
|
||||
@@ -228,6 +236,7 @@ describe('OAuthManagement page', () => {
|
||||
'oauth-codex',
|
||||
expect.stringContaining('width=540'),
|
||||
);
|
||||
expect(collectText(root!.root)).toContain('ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1600);
|
||||
@@ -253,6 +262,111 @@ describe('OAuthManagement page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('reveals manual callback input after delay and submits the pasted callback url', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
{
|
||||
provider: 'claude',
|
||||
label: 'Claude',
|
||||
platform: 'claude',
|
||||
enabled: true,
|
||||
loginType: 'oauth',
|
||||
requiresProjectId: false,
|
||||
supportsDirectAccountRouting: true,
|
||||
supportsCloudValidation: true,
|
||||
supportsNativeProxy: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
apiMock.getOAuthConnections.mockResolvedValue({ items: [], total: 0, limit: 100, offset: 0 });
|
||||
apiMock.startOAuthProvider.mockResolvedValue({
|
||||
provider: 'claude',
|
||||
state: 'oauth-state-456',
|
||||
authorizationUrl: 'https://claude.ai/oauth/authorize?state=oauth-state-456',
|
||||
instructions: {
|
||||
redirectUri: 'http://localhost:54545/callback',
|
||||
callbackPort: 54545,
|
||||
callbackPath: '/callback',
|
||||
manualCallbackDelayMs: 15000,
|
||||
sshTunnelCommand: 'ssh -L 54545:127.0.0.1:54545 root@metapi.example -p 22',
|
||||
},
|
||||
});
|
||||
apiMock.getOAuthSession.mockResolvedValue({
|
||||
provider: 'claude',
|
||||
state: 'oauth-state-456',
|
||||
status: 'pending',
|
||||
});
|
||||
apiMock.submitOAuthManualCallback.mockResolvedValue({ success: true });
|
||||
|
||||
let root: ReturnType<typeof create> | null = null;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<ToastProvider>
|
||||
<MemoryRouter>
|
||||
<OAuthManagement />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
const startButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('连接 Claude')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await startButton.props.onClick();
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(collectText(root!.root)).not.toContain('提交回调 URL');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15000);
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
expect(collectText(root!.root)).toContain('提交回调 URL');
|
||||
});
|
||||
|
||||
const textInput = root!.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.value !== undefined
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
textInput.props.onChange({ target: { value: 'http://localhost:54545/callback?code=test-code&state=oauth-state-456' } });
|
||||
});
|
||||
|
||||
const submitButton = root!.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('提交回调 URL')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await submitButton.props.onClick();
|
||||
});
|
||||
await vi.waitFor(async () => {
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(apiMock.submitOAuthManualCallback).toHaveBeenCalledWith(
|
||||
'oauth-state-456',
|
||||
'http://localhost:54545/callback?code=test-code&state=oauth-state-456',
|
||||
);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows oauth connection status metadata and allows deleting a connection', async () => {
|
||||
apiMock.getOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo } from '../api.js';
|
||||
import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1500;
|
||||
const CONNECTION_PAGE_LIMIT = 100;
|
||||
@@ -7,6 +7,8 @@ const CONNECTION_PAGE_LIMIT = 100;
|
||||
type ActiveSession = {
|
||||
provider: string;
|
||||
state: string;
|
||||
authorizationUrl: string;
|
||||
instructions: OAuthStartInstructions;
|
||||
};
|
||||
|
||||
function openOAuthPopup(provider: string, authorizationUrl: string) {
|
||||
@@ -39,6 +41,9 @@ export default function OAuthManagement() {
|
||||
const [activeSession, setActiveSession] = useState<ActiveSession | null>(null);
|
||||
const [sessionMessage, setSessionMessage] = useState('');
|
||||
const [actionLoadingKey, setActionLoadingKey] = useState('');
|
||||
const [manualCallbackVisible, setManualCallbackVisible] = useState(false);
|
||||
const [manualCallbackUrl, setManualCallbackUrl] = useState('');
|
||||
const [manualCallbackSubmitting, setManualCallbackSubmitting] = useState(false);
|
||||
|
||||
const loadConnections = async () => {
|
||||
const response = await api.getOAuthConnections({
|
||||
@@ -107,6 +112,25 @@ export default function OAuthManagement() {
|
||||
};
|
||||
}, [activeSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) {
|
||||
setManualCallbackVisible(false);
|
||||
setManualCallbackUrl('');
|
||||
setManualCallbackSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setManualCallbackVisible(false);
|
||||
setManualCallbackUrl('');
|
||||
setManualCallbackSubmitting(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setManualCallbackVisible(true);
|
||||
}, Math.max(0, activeSession.instructions.manualCallbackDelayMs || 0));
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeSession]);
|
||||
|
||||
const handleStart = async (provider: OAuthProviderInfo, accountId?: number) => {
|
||||
const actionKey = `start:${provider.provider}:${accountId || 0}`;
|
||||
setActionLoadingKey(actionKey);
|
||||
@@ -129,6 +153,8 @@ export default function OAuthManagement() {
|
||||
setActiveSession({
|
||||
provider: started.provider,
|
||||
state: started.state,
|
||||
authorizationUrl: started.authorizationUrl,
|
||||
instructions: started.instructions,
|
||||
});
|
||||
openOAuthPopup(provider.provider, started.authorizationUrl);
|
||||
} catch (error: any) {
|
||||
@@ -138,6 +164,24 @@ export default function OAuthManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitManualCallback = async () => {
|
||||
if (!activeSession) return;
|
||||
const callbackUrl = manualCallbackUrl.trim();
|
||||
if (!callbackUrl) {
|
||||
setSessionMessage('请输入完整的回调 URL');
|
||||
return;
|
||||
}
|
||||
setManualCallbackSubmitting(true);
|
||||
try {
|
||||
await api.submitOAuthManualCallback(activeSession.state, callbackUrl);
|
||||
setSessionMessage('回调已提交,等待授权完成');
|
||||
} catch (error: any) {
|
||||
setSessionMessage(error?.message || '提交回调 URL 失败');
|
||||
} finally {
|
||||
setManualCallbackSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (accountId: number) => {
|
||||
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
||||
const confirmed = window.confirm('确定要删除这个 OAuth 连接吗?');
|
||||
@@ -171,6 +215,62 @@ export default function OAuthManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSession && (
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>授权指引</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', display: 'grid', gap: 10 }}>
|
||||
<div>回调地址固定为 {activeSession.instructions.redirectUri}</div>
|
||||
<div>
|
||||
打开授权页后,如果你在云端部署 metapi,请先在本地运行 SSH 隧道,再继续登录。
|
||||
</div>
|
||||
{activeSession.instructions.sshTunnelCommand && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>SSH 隧道命令</div>
|
||||
<code>{activeSession.instructions.sshTunnelCommand}</code>
|
||||
</div>
|
||||
)}
|
||||
{activeSession.instructions.sshTunnelKeyCommand && (
|
||||
<div>
|
||||
<div style={{ marginBottom: 4 }}>SSH Key 隧道命令</div>
|
||||
<code>{activeSession.instructions.sshTunnelKeyCommand}</code>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
如果授权完成后浏览器停在 localhost 错误页,复制完整地址,等待 {Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。
|
||||
</div>
|
||||
{manualCallbackVisible ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={manualCallbackUrl}
|
||||
onChange={(event) => setManualCallbackUrl(event.target.value)}
|
||||
placeholder="粘贴完整的 callback URL"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmitManualCallback}
|
||||
disabled={manualCallbackSubmitting}
|
||||
>
|
||||
{manualCallbackSubmitting ? '提交中...' : '提交回调 URL'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => openOAuthPopup(activeSession.provider, activeSession.authorizationUrl)}
|
||||
>
|
||||
重新打开授权页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>手动回调入口将在几秒后可用。</div>
|
||||
)}
|
||||
</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 }}>
|
||||
|
||||
Reference in New Issue
Block a user