fix: improve Codex account settings

This commit is contained in:
zhoujun0601
2026-08-13 09:20:49 -04:00
parent eb7f9fd66c
commit fc8e24d8a9
4 changed files with 343 additions and 154 deletions
+160 -120
View File
@@ -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<Account[]>([]),
[result, setResult] = useState<any>(null),
[deviceLogin, setDeviceLogin] = useState<DeviceLogin | null>(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<Omit<DeviceLogin, "accountId">>(
`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<Account>("accounts", {
displayName: `账号 ${xs.length + 1}`,
expectedKind: newKind,
});
await load();
setActive(x.id);
setResult(await post(`accounts/${x.id}/login/device`));
const result = await post<Omit<DeviceLogin, "accountId">>(
`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 (
<div className="panel form wide">
<div className="settings-title">
<div>
<h2>Codex 账户与工作区</h2>
<p>
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
</p>
</div>
<label>
新连接类型
<select
value={newKind}
onChange={(e) =>
setNewKind(e.target.value as "personal" | "team")
}
>
<option value="team">Team / Business 工作区</option>
<option value="personal">个人订阅</option>
</select>
</label>
<button disabled={busy} onClick={add}>
{busy && active === 0 ? "服务启动中…" : "添加账号"}
</button>
<div className="codex-settings wide">
<div className="codex-heading">
<h2>Codex 账户与工作区</h2>
<p>个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。</p>
</div>
<section className="panel add-account-card">
<div>
<h3>添加新连接</h3>
<p>选择要连接的订阅类型,然后使用设备码完成授权。</p>
</div>
<div className="add-account-controls">
<label>
连接类型
<select
value={newKind}
onChange={(e) =>
setNewKind(e.target.value as "personal" | "team")
}
>
<option value="team">Team / Business 工作区</option>
<option value="personal">个人订阅</option>
</select>
</label>
<button disabled={busy} onClick={add}>
{busy && active === 0 ? "服务启动中…" : "添加账号"}
</button>
</div>
</section>
<div className="account-list">
{xs.length === 0 && (
<div className="panel empty account-empty">
尚未添加 Codex 账号,请使用上方的“添加账号”创建连接。
</div>
)}
{xs.map((x) => (
<div className="account-row" key={x.id}>
<span className={"dot " + (x.connected ? "ok" : "")} />
<div>
<input
aria-label="连接名称"
defaultValue={x.displayName}
onBlur={async (e) => {
const name = e.target.value.trim();
if (name && name !== x.displayName) {
<section className="panel account-card" key={x.id}>
<div className="account-card-main">
<span className={"dot " + (x.connected ? "ok" : "")} />
<div className="account-details">
<input
aria-label="连接名称"
defaultValue={x.displayName}
onBlur={async (e) => {
const name = e.target.value.trim();
if (name && name !== x.displayName) {
await put(`accounts/${x.id}`, {
displayName: name,
expectedKind: x.expectedKind,
});
load();
}
}}
/>
<div className="account-meta">
<span>{x.email || "尚未登录"}</span>
<span>{planLabel(x.planType)}</span>
<span className={x.validationStatus === "mismatch" ? "error" : ""}>
{validationLabel(x)}
</span>
</div>
{x.possibleDuplicate && (
<small className="hint">
同一邮箱已有相同类型连接,请确认没有重复授权同一工作区
</small>
)}
</div>
</div>
<div className="account-card-actions">
<label>
预期连接类型
<select
aria-label="预期连接类型"
value={x.expectedKind}
onChange={async (e) => {
await put(`accounts/${x.id}`, {
displayName: name,
expectedKind: x.expectedKind,
displayName: x.displayName,
expectedKind: e.target.value,
});
load();
}
}}
/>
<small>
{x.email || "尚未登录"} · {planLabel(x.planType)}
</small>
<small className={x.validationStatus === "mismatch" ? "error" : ""}>
{validationLabel(x)}
</small>
{x.possibleDuplicate && (
<small className="hint">
同一邮箱已有相同类型连接,请确认没有重复授权同一工作区
</small>
)}
}}
>
<option value="any">不校验</option>
<option value="personal">个人订阅</option>
<option value="team">Team / Business</option>
</select>
</label>
<div className="account-buttons">
<button className="secondary" disabled={busy} onClick={() => login(x.id)}>
{busy && active === x.id ? "服务启动中…" : "设备码登录"}
</button>
<button
className="secondary"
disabled={busy}
onClick={async () => {
if (!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`)) return;
try {
setErr("");
await post(`accounts/${x.id}/logout`);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
await load();
} catch (q) {
setErr((q as Error).message);
}
}}
>
退出
</button>
<button
className="icon danger"
disabled={busy}
title="删除连接"
aria-label={`删除“${x.displayName}”`}
onClick={async () => {
if (!confirm(`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`)) return;
try {
setErr("");
await del(`accounts/${x.id}`);
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
if (active === x.id) setActive(0);
if (+localStorage.accountId === x.id) localStorage.removeItem("accountId");
await load();
} catch (q) {
setErr((q as Error).message);
}
}}
>
<Trash2 />
</button>
</div>
</div>
<select
aria-label="预期连接类型"
value={x.expectedKind}
onChange={async (e) => {
await put(`accounts/${x.id}`, {
displayName: x.displayName,
expectedKind: e.target.value,
});
load();
}}
>
<option value="any">不校验</option>
<option value="personal">个人订阅</option>
<option value="team">Team / Business</option>
</select>
<button
className="secondary"
disabled={busy}
onClick={() => login(x.id)}
>
{busy && active === x.id ? "服务启动中…" : "设备码登录"}
</button>
<button
className="secondary"
disabled={busy}
onClick={async () => {
if (!confirm(`确定要退出“${x.displayName}”的 Codex 账号吗?`))
return;
await post(`accounts/${x.id}/logout`);
load();
}}
>
退出
</button>
<button
className="icon danger"
disabled={busy}
title="删除连接"
onClick={async () => {
if (
confirm(
`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`,
)
) {
await del(`accounts/${x.id}`);
if (+localStorage.accountId === x.id)
localStorage.removeItem("accountId");
load();
}
}}
>
<Trash2 />
</button>
</div>
{deviceLogin?.accountId === x.id && (
<div className="codebox">
<span>
在浏览器中访问{" "}
<a href={deviceLogin.verificationUrl} target="_blank" rel="noreferrer">
{deviceLogin.verificationUrl}
</a>
,然后输入设备码
</span>
<strong>{deviceLogin.userCode}</strong>
</div>
)}
</section>
))}
</div>
{result && (
<div className="codebox">
<span>
为“{xs.find((x) => x.id === active)?.displayName}”访问{" "}
<a href={result.verificationUrl} target="_blank" rel="noreferrer">
{result.verificationUrl}
</a>
</span>
<strong>{result.userCode}</strong>
</div>
)}
{err && <p className="error">{err}</p>}
</div>
);
+123 -32
View File
@@ -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) {
* {
+58
View File
@@ -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);
});