diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index cb13c4b..eed23cb 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -1,4 +1,5 @@ import Fastify, { type FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -427,6 +428,113 @@ describe('oauth routes', { timeout: 15_000 }, () => { expect(accounts).toEqual([]); }); + it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => { + const buildTokenExchange = (accountId: string) => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: `oauth-access-token-${accountId}`, + refresh_token: `oauth-refresh-token-${accountId}`, + id_token: buildJwt({ + email: 'team-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: accountId, + chatgpt_plan_type: 'team', + }, + }), + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const buildModelDiscovery = (modelId: string) => ({ + ok: true, + status: 200, + json: async () => ({ + models: [{ id: modelId }], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + fetchMock + .mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-a')) + .mockResolvedValueOnce(buildModelDiscovery('gpt-5.4')) + .mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-b')) + .mockResolvedValueOnce(buildModelDiscovery('gpt-5.4')); + + const startFirstResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const firstSession = startFirstResponse.json() as { state: string }; + + const submitFirstCallback = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(firstSession.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(firstSession.state)}&code=oauth-code-team-a`, + }, + }); + expect(submitFirstCallback.statusCode).toBe(200); + + const startSecondResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const secondSession = startSecondResponse.json() as { state: string }; + + const submitSecondCallback = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(secondSession.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(secondSession.state)}&code=oauth-code-team-b`, + }, + }); + expect(submitSecondCallback.statusCode).toBe(200); + + const accounts = await db.select().from(schema.accounts) + .where(eq(schema.accounts.oauthProvider, 'codex')) + .orderBy(schema.accounts.id) + .all(); + + expect(accounts).toHaveLength(2); + expect(accounts.map((account) => account.oauthAccountKey)).toEqual([ + 'chatgpt-team-account-a', + 'chatgpt-team-account-b', + ]); + + const connectionsResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(connectionsResponse.statusCode).toBe(200); + expect(connectionsResponse.json()).toMatchObject({ + total: 2, + items: expect.arrayContaining([ + expect.objectContaining({ + provider: 'codex', + email: 'team-user@example.com', + accountKey: 'chatgpt-team-account-a', + }), + expect.objectContaining({ + provider: 'codex', + email: 'team-user@example.com', + accountKey: 'chatgpt-team-account-b', + }), + ]), + }); + }); + it('rejects malformed manual callback submissions', async () => { const startResponse = await app.inject({ method: 'POST', diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index 4332755..0bd9213 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -177,7 +177,7 @@ async function findExistingOauthAccount(input: { if (byKey) return byKey; } - if (email) { + if (!accountKey && email) { const byEmail = await db.select().from(schema.accounts).where(and( eq(schema.accounts.oauthProvider, input.provider), eq(schema.accounts.username, email), diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index 0ef8f24..689bc23 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -95,6 +95,7 @@ describe('OAuthManagement page', () => { accountId: 7, provider: 'codex', email: 'codex-user@example.com', + accountKey: 'chatgpt-account-123', planType: 'plus', modelCount: 3, modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], @@ -126,6 +127,7 @@ describe('OAuthManagement page', () => { expect(text).toContain('codex-user@example.com'); expect(text).toContain('plus'); expect(text).toContain('3 个模型'); + expect(text).toContain('chatgpt-account-123'); }); } finally { root?.unmount(); @@ -167,6 +169,7 @@ describe('OAuthManagement page', () => { accountId: 7, provider: 'codex', email: 'codex-user@example.com', + accountKey: 'chatgpt-account-123', planType: 'plus', modelCount: 3, modelsPreview: ['gpt-5', 'gpt-5-mini', 'gpt-5.2-codex'], @@ -236,6 +239,8 @@ describe('OAuthManagement page', () => { 'oauth-codex', expect.stringContaining('width=540'), ); + expect(collectText(root!.root)).toContain('本地部署'); + expect(collectText(root!.root)).toContain('云端部署'); expect(collectText(root!.root)).toContain('ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22'); await act(async () => { @@ -334,10 +339,11 @@ describe('OAuthManagement page', () => { await vi.waitFor(async () => { await flushMicrotasks(); expect(collectText(root!.root)).toContain('提交回调 URL'); + expect(collectText(root!.root)).toContain('手动回调'); }); const textInput = root!.root.find((node) => ( - node.type === 'input' + node.type === 'textarea' && node.props.value !== undefined )); @@ -362,6 +368,7 @@ describe('OAuthManagement page', () => { 'oauth-state-456', 'http://localhost:54545/callback?code=test-code&state=oauth-state-456', ); + expect(collectText(root!.root)).toContain('如果浏览器停在 localhost 错误页'); } finally { root?.unmount(); } diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index 7fe01a7..d6fd9bb 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { api, type OAuthConnectionInfo, type OAuthProviderInfo, type OAuthStartInstructions } from '../api.js'; const POLL_INTERVAL_MS = 1500; @@ -34,6 +34,46 @@ function resolveConnectionStatusLabel(status?: string): string { return status === 'abnormal' ? '异常' : '正常'; } +function renderCodeBlock(value: string) { + return ( + + {value} + + ); +} + +function renderGuideCard(title: string, description: string, children?: ReactNode) { + return ( +
+
+
{title}
+
{description}
+
+ {children} +
+ ); +} + export default function OAuthManagement() { const [providers, setProviders] = useState([]); const [connections, setConnections] = useState([]); @@ -218,54 +258,89 @@ export default function OAuthManagement() { {activeSession && (
授权指引
-
-
回调地址固定为 {activeSession.instructions.redirectUri}
-
- 打开授权页后,如果你在云端部署 metapi,请先在本地运行 SSH 隧道,再继续登录。 +
+
+
固定回调地址
+ {renderCodeBlock(activeSession.instructions.redirectUri)}
- {activeSession.instructions.sshTunnelCommand && ( -
-
SSH 隧道命令
- {activeSession.instructions.sshTunnelCommand} -
+ + {renderGuideCard( + '本地部署', + 'metapi 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。', +
+ 如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 metapi。 +
, )} - {activeSession.instructions.sshTunnelKeyCommand && ( -
-
SSH Key 隧道命令
- {activeSession.instructions.sshTunnelKeyCommand} -
- )} -
- 如果授权完成后浏览器停在 localhost 错误页,复制完整地址,等待 {Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。 -
- {manualCallbackVisible ? ( -
- setManualCallbackUrl(event.target.value)} - placeholder="粘贴完整的 callback URL" - /> -
- - + + {activeSession.instructions.sshTunnelCommand + ? renderGuideCard( + '云端部署', + 'metapi 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。', +
+
常规 SSH 隧道
+ {renderCodeBlock(activeSession.instructions.sshTunnelCommand)} + {activeSession.instructions.sshTunnelKeyCommand && ( + <> +
SSH Key 隧道
+ {renderCodeBlock(activeSession.instructions.sshTunnelKeyCommand)} + + )} +
, + ) + : renderGuideCard( + '云端部署', + '当前没有检测到远程主机地址。如果你实际是云端部署,请用能访问服务器 127.0.0.1 回调端口的 SSH 隧道方式完成授权。', + )} + + {renderGuideCard( + '手动回调', + `如果浏览器停在 localhost 错误页,复制浏览器地址栏里的完整 URL,等待 ${Math.max(1, Math.round(activeSession.instructions.manualCallbackDelayMs / 1000))} 秒后粘贴回来。`, + manualCallbackVisible ? ( +
+