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 (
+
{activeSession.instructions.sshTunnelCommand}
- {activeSession.instructions.sshTunnelKeyCommand}
-