diff --git a/backend/internal/web/dist/index.html b/backend/internal/web/dist/index.html index 5436cf5..2c645a3 100644 --- a/backend/internal/web/dist/index.html +++ b/backend/internal/web/dist/index.html @@ -1,3 +1,3 @@ - - + +
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 7fd9583..acd0bd9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -52,6 +52,11 @@ type Account = { possibleDuplicate: boolean; connected: boolean; }; +type DeviceLogin = { + accountId: number; + verificationUrl: string; + userCode: string; +}; type Dash = { accountId: number; displayName: string; @@ -685,7 +690,7 @@ function General() { } function CodexSettings() { const [xs, setXs] = useState([]), - [result, setResult] = useState(null), + [deviceLogin, setDeviceLogin] = useState(null), [active, setActive] = useState(0), [newKind, setNewKind] = useState<"personal" | "team">("team"), [busy, setBusy] = useState(false), @@ -695,13 +700,17 @@ function CodexSettings() { load(); }, []); useEffect(() => { - if (!result || !active) return; + if (!deviceLogin) return; const started = Date.now(); const timer = setInterval(async () => { const accounts = await load(); - const account = accounts.find((x) => x.id === active); + const account = accounts.find((x) => x.id === deviceLogin.accountId); + if (!account) { + setDeviceLogin(null); + clearInterval(timer); + return; + } if ( - account && ["matched", "mismatch"].includes(account.validationStatus) ) clearInterval(timer); @@ -709,13 +718,17 @@ function CodexSettings() { clearInterval(timer); }, 2000); return () => clearInterval(timer); - }, [result, active]); + }, [deviceLogin]); const login = async (id: number) => { try { setBusy(true); setActive(id); setErr(""); - setResult(await post(`accounts/${id}/login/device`)); + setDeviceLogin(null); + const result = await post>( + `accounts/${id}/login/device`, + ); + setDeviceLogin({ accountId: id, ...result }); } catch (q) { setErr((q as Error).message); } finally { @@ -727,13 +740,17 @@ function CodexSettings() { setBusy(true); setActive(0); setErr(""); + setDeviceLogin(null); const x = await post("accounts", { displayName: `账号 ${xs.length + 1}`, expectedKind: newKind, }); await load(); setActive(x.id); - setResult(await post(`accounts/${x.id}/login/device`)); + const result = await post>( + `accounts/${x.id}/login/device`, + ); + setDeviceLogin({ accountId: x.id, ...result }); } catch (q) { setErr((q as Error).message); } finally { @@ -741,128 +758,151 @@ function CodexSettings() { } }; return ( -
-
-
-

Codex 账户与工作区

-

- 个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。 -

-
- - +
+
+

Codex 账户与工作区

+

个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。

+
+
+

添加新连接

+

选择要连接的订阅类型,然后使用设备码完成授权。

+
+
+ + +
+
+ {xs.length === 0 && ( +
+ 尚未添加 Codex 账号,请使用上方的“添加账号”创建连接。 +
+ )} {xs.map((x) => ( -
- -
- { - const name = e.target.value.trim(); - if (name && name !== x.displayName) { +
+
+ +
+ { + const name = e.target.value.trim(); + if (name && name !== x.displayName) { + await put(`accounts/${x.id}`, { + displayName: name, + expectedKind: x.expectedKind, + }); + load(); + } + }} + /> +
+ {x.email || "尚未登录"} + {planLabel(x.planType)} + + {validationLabel(x)} + +
+ {x.possibleDuplicate && ( + + 同一邮箱已有相同类型连接,请确认没有重复授权同一工作区 + + )} +
+
+
+ +
+ + + +
- - - - -
+ {deviceLogin?.accountId === x.id && ( +
+ + 在浏览器中访问{" "} + + {deviceLogin.verificationUrl} + + ,然后输入设备码 + + {deviceLogin.userCode} +
+ )} + ))}
- {result && ( -
- - 为“{xs.find((x) => x.id === active)?.displayName}”访问{" "} - - {result.verificationUrl} - - - {result.userCode} -
- )} {err &&

{err}

}
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f3a9e21..2a6f813 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -443,18 +443,10 @@ header p { .checks input { width: auto; } -.actions, -.settings-title { +.actions { display: flex; gap: 10px; } -.settings-title { - justify-content: space-between; - align-items: start; -} -.settings-title p { - margin-bottom: 0; -} .danger { background: transparent; border: 1px solid var(--danger); @@ -464,43 +456,126 @@ header p { padding: 10px; } .codebox { - padding: 16px; + grid-column: 1 / -1; + padding: 16px 18px; border: 1px dashed var(--accent); border-radius: 12px; display: flex; justify-content: space-between; align-items: center; + gap: 18px; + background: color-mix(in srgb, var(--accent) 6%, var(--panel)); + overflow-wrap: anywhere; } .codebox strong { font-family: monospace; - font-size: 20px; + font-size: 24px; + letter-spacing: 0.08em; color: var(--accent); + white-space: nowrap; +} +.codex-settings { + display: flex; + flex-direction: column; + gap: 18px; +} +.codex-heading h2, +.add-account-card h3 { + margin: 0; +} +.codex-heading h2 { + font-size: 26px; +} +.codex-heading p, +.add-account-card p { + color: var(--muted); + margin: 7px 0 0; +} +.add-account-card { + padding: 22px 24px; + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; +} +.add-account-controls { + display: flex; + align-items: end; + gap: 10px; +} +.add-account-controls label { + min-width: 250px; +} +.add-account-controls button { + min-height: 43px; + white-space: nowrap; } .account-list { display: flex; flex-direction: column; - gap: 10px; + gap: 14px; } -.account-row { +.account-card { display: grid; - grid-template-columns: auto minmax(180px, 1fr) auto auto auto auto; - gap: 10px; - align-items: center; - padding: 14px; - border: 1px solid var(--border); - border-radius: 12px; + grid-template-columns: minmax(0, 1fr) auto; + gap: 20px 28px; + padding: 22px 24px; } -.account-row > div { +.account-card-main { + display: flex; + align-items: flex-start; + gap: 14px; + min-width: 0; +} +.account-card-main .dot { + margin-top: 15px; +} +.account-details { display: flex; flex-direction: column; - gap: 5px; + gap: 8px; + min-width: 0; + flex: 1; } -.account-row input { - padding: 7px; +.account-details input { + width: 100%; + max-width: 360px; + padding: 9px 11px; font-weight: 700; + font-size: 16px; } -.account-row small { - padding-left: 8px; +.account-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + color: var(--muted); + font-size: 13px; +} +.account-meta span + span { + position: relative; +} +.account-meta span + span::before { + content: "·"; + position: absolute; + left: -10px; + color: var(--border); +} +.account-card-actions { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + min-width: 285px; +} +.account-buttons { + display: flex; + gap: 8px; +} +.account-buttons .secondary:first-child { + flex: 1; +} +.account-empty { + padding: 44px 24px; } a { color: var(--accent); @@ -575,11 +650,30 @@ a { .fresh { display: none; } - .account-row { - grid-template-columns: auto 1fr; + .add-account-card, + .add-account-controls { + align-items: stretch; + flex-direction: column; } - .account-row button { - grid-column: auto; + .add-account-controls label { + min-width: 0; + } + .account-card { + grid-template-columns: 1fr; + padding: 18px; + } + .account-card-actions { + min-width: 0; + } + .account-buttons button { + flex: 1; + } + .account-buttons .icon { + flex: none; + } + .codebox { + flex-direction: column; + align-items: flex-start; } .limit { grid-template-columns: 1fr; @@ -592,9 +686,6 @@ a { .limit footer { grid-column: 1; } - .settings-title { - flex-direction: column; - } } @media (prefers-reduced-motion: reduce) { * { diff --git a/frontend/tests/settings-layout.spec.ts b/frontend/tests/settings-layout.spec.ts index 68a7980..0d9ab8b 100644 --- a/frontend/tests/settings-layout.spec.ts +++ b/frontend/tests/settings-layout.spec.ts @@ -111,3 +111,61 @@ test("programmatic tab changes do not clamp an existing scroll position", async await expect.poll(async () => (await layout(page)).scrollY).toBe(before.scrollY); await expect.poll(async () => (await layout(page)).content).toEqual(before.content); }); + +test("deleting a newly added account clears its device authorization", async ({ page }) => { + let accounts: Array<(typeof responses.accounts)[number]> = []; + await page.route("**/api/v1/**", async (route) => { + const request = route.request(); + const key = new URL(request.url()).pathname.replace("/api/v1/", ""); + if (key === "accounts" && request.method() === "GET") + return route.fulfill({ json: accounts }); + if (key === "accounts" && request.method() === "POST") { + const account = { + ...responses.accounts[0], + id: 2, + displayName: "账号 1", + email: "", + connected: false, + validationStatus: "pending" as const, + }; + accounts = [account]; + return route.fulfill({ json: account }); + } + if (key === "accounts/2/login/device") + return route.fulfill({ + json: { + verificationUrl: "https://auth.openai.com/codex/device", + userCode: "PLTJ-7M6I6", + }, + }); + if (key === "accounts/2" && request.method() === "DELETE") { + accounts = []; + return route.fulfill({ json: { ok: true } }); + } + return route.fulfill({ json: responses[key] ?? {} }); + }); + page.on("dialog", (dialog) => dialog.accept()); + await page.goto("/settings"); + await page.getByRole("tab", { name: "Codex" }).click(); + await page.getByRole("button", { name: "添加账号" }).click(); + await expect(page.getByText("PLTJ-7M6I6")).toBeVisible(); + await expect(page.getByRole("link", { name: "https://auth.openai.com/codex/device" })).toBeVisible(); + + await page.getByRole("button", { name: "删除“账号 1”" }).click(); + await expect(page.getByText("PLTJ-7M6I6")).toHaveCount(0); + await expect(page.getByRole("link", { name: "https://auth.openai.com/codex/device" })).toHaveCount(0); + await expect(page.getByText("尚未添加 Codex 账号", { exact: false })).toBeVisible(); +}); + +test("Codex account cards fit within the viewport", async ({ page }) => { + await openSettings(page); + await page.getByRole("tab", { name: "Codex" }).click(); + const overflow = await page.evaluate(() => ({ + documentWidth: document.documentElement.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + cardWidth: document.querySelector(".account-card")!.getBoundingClientRect().width, + contentWidth: document.querySelector(".settings-content")!.getBoundingClientRect().width, + })); + expect(overflow.documentWidth).toBeLessThanOrEqual(overflow.viewportWidth); + expect(overflow.cardWidth).toBeLessThanOrEqual(overflow.contentWidth); +});