fix: improve Codex account settings
This commit is contained in:
+160
-120
@@ -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
@@ -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) {
|
||||
* {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user