This commit is contained in:
+7
-2
@@ -85,8 +85,13 @@ export const post = <T>(
|
||||
{ method: "POST", body: JSON.stringify(value), signal },
|
||||
timeoutMs,
|
||||
);
|
||||
export const put = <T>(path: string, decoder: Decoder<T>, value: unknown) =>
|
||||
api(path, decoder, { method: "PUT", body: JSON.stringify(value) });
|
||||
export const put = <T>(
|
||||
path: string,
|
||||
decoder: Decoder<T>,
|
||||
value: unknown,
|
||||
timeoutMs = 30_000,
|
||||
) =>
|
||||
api(path, decoder, { method: "PUT", body: JSON.stringify(value) }, timeoutMs);
|
||||
export const del = <T>(path: string, decoder: Decoder<T>) =>
|
||||
api(path, decoder, { method: "DELETE" });
|
||||
|
||||
|
||||
+147
-160
@@ -39,14 +39,12 @@ import {
|
||||
decodeAction,
|
||||
decodeCode,
|
||||
decodeDashboard,
|
||||
decodeDeviceLogin,
|
||||
decodeGeneral,
|
||||
decodeOK,
|
||||
decodeSMTP,
|
||||
decodeTelegram,
|
||||
type Account,
|
||||
type Dashboard as Dash,
|
||||
type DeviceLogin,
|
||||
type GeneralSettings,
|
||||
type Limit,
|
||||
type SMTPSettingsForm,
|
||||
@@ -1083,8 +1081,8 @@ function General() {
|
||||
}
|
||||
function CodexSettings() {
|
||||
const [xs, setXs] = useState<Account[]>([]),
|
||||
[deviceLogin, setDeviceLogin] = useState<DeviceLogin | null>(null),
|
||||
[active, setActive] = useState(0),
|
||||
[newAuthIndex, setNewAuthIndex] = useState(""),
|
||||
[newKind, setNewKind] = useState<"personal" | "team">("team"),
|
||||
[newPublicVisible, setNewPublicVisible] = useState(false),
|
||||
[busy, setBusy] = useState(false),
|
||||
@@ -1097,81 +1095,62 @@ function CodexSettings() {
|
||||
useEffect(() => {
|
||||
void load().catch((error) => setErr(toErrorMessage(error)));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!deviceLogin) return;
|
||||
const started = Date.now();
|
||||
const controller = new AbortController();
|
||||
let timer = 0;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const accounts = await load(controller.signal);
|
||||
const account = accounts.find((x) => x.id === deviceLogin.accountId);
|
||||
if (!account) {
|
||||
setDeviceLogin(null);
|
||||
return;
|
||||
}
|
||||
if (["matched", "mismatch"].includes(account.validationStatus)) return;
|
||||
if (Date.now() - started >= 120_000) {
|
||||
setErr("设备码登录检测已超时,请重新生成设备码");
|
||||
return;
|
||||
}
|
||||
timer = window.setTimeout(poll, 2000);
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setErr(toErrorMessage(error));
|
||||
timer = window.setTimeout(poll, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(poll, 2000);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [deviceLogin]);
|
||||
const login = async (id: number) => {
|
||||
const updateAccount = async (
|
||||
account: Account,
|
||||
changes: Partial<
|
||||
Pick<
|
||||
Account,
|
||||
"displayName" | "authIndex" | "expectedKind" | "publicVisible"
|
||||
>
|
||||
>,
|
||||
) => {
|
||||
try {
|
||||
setBusy(true);
|
||||
setActive(id);
|
||||
setErr("");
|
||||
setDeviceLogin(null);
|
||||
const result = await post(
|
||||
`accounts/${id}/login/device`,
|
||||
decodeDeviceLogin,
|
||||
{},
|
||||
undefined,
|
||||
60_000,
|
||||
await put(
|
||||
`accounts/${account.id}`,
|
||||
decodeOK,
|
||||
{
|
||||
displayName: changes.displayName ?? account.displayName,
|
||||
authIndex: changes.authIndex ?? account.authIndex,
|
||||
expectedKind: changes.expectedKind ?? account.expectedKind,
|
||||
publicVisible: changes.publicVisible ?? account.publicVisible,
|
||||
},
|
||||
changes.authIndex === undefined ? 30_000 : 60_000,
|
||||
);
|
||||
setDeviceLogin({ accountId: id, ...result });
|
||||
} catch (q) {
|
||||
setErr(toErrorMessage(q));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
await load();
|
||||
return true;
|
||||
} catch (error) {
|
||||
setErr(toErrorMessage(error));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const add = async () => {
|
||||
const authIndex = newAuthIndex.trim();
|
||||
if (!authIndex) {
|
||||
setErr("请输入 CPA authIndex");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setBusy(true);
|
||||
setActive(0);
|
||||
setErr("");
|
||||
setDeviceLogin(null);
|
||||
const x = await post("accounts", (value) => decodeAccounts([value])[0], {
|
||||
displayName: `账号 ${xs.length + 1}`,
|
||||
expectedKind: newKind,
|
||||
publicVisible: newPublicVisible,
|
||||
});
|
||||
await load();
|
||||
setActive(x.id);
|
||||
const result = await post(
|
||||
`accounts/${x.id}/login/device`,
|
||||
decodeDeviceLogin,
|
||||
{},
|
||||
const account = await post(
|
||||
"accounts",
|
||||
(value) => decodeAccounts([value])[0],
|
||||
{
|
||||
displayName: "",
|
||||
authIndex,
|
||||
expectedKind: newKind,
|
||||
publicVisible: newPublicVisible,
|
||||
},
|
||||
undefined,
|
||||
60_000,
|
||||
);
|
||||
setDeviceLogin({ accountId: x.id, ...result });
|
||||
} catch (q) {
|
||||
setErr(toErrorMessage(q));
|
||||
setNewAuthIndex("");
|
||||
await load();
|
||||
setActive(account.id);
|
||||
} catch (error) {
|
||||
setErr(toErrorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -1181,21 +1160,32 @@ function CodexSettings() {
|
||||
<div className="codex-heading">
|
||||
<h2>Codex 账户与工作区</h2>
|
||||
<p>
|
||||
个人订阅和 Team 工作区请分别添加为独立连接;同一邮箱可以添加多次。
|
||||
账号凭据和 OAuth 刷新由 CLIProxyAPI 管理;这里通过 CPA authIndex
|
||||
绑定需要展示的 Codex 账号。
|
||||
</p>
|
||||
</div>
|
||||
<section className="panel add-account-card">
|
||||
<div>
|
||||
<h3>添加新连接</h3>
|
||||
<p>选择要连接的订阅类型,然后使用设备码完成授权。</p>
|
||||
<h3>添加 CPA 账号</h3>
|
||||
<p>authIndex 可从 CLIProxyAPI 的认证文件列表中取得。</p>
|
||||
</div>
|
||||
<div className="add-account-controls">
|
||||
<label>
|
||||
连接类型
|
||||
CPA authIndex
|
||||
<input
|
||||
aria-label="CPA authIndex"
|
||||
autoComplete="off"
|
||||
placeholder="例如 6c4894e0cb312814"
|
||||
value={newAuthIndex}
|
||||
onChange={(event) => setNewAuthIndex(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
预期连接类型
|
||||
<select
|
||||
value={newKind}
|
||||
onChange={(e) =>
|
||||
setNewKind(e.target.value as "personal" | "team")
|
||||
onChange={(event) =>
|
||||
setNewKind(event.target.value as "personal" | "team")
|
||||
}
|
||||
>
|
||||
<option value="team">Team / Business 工作区</option>
|
||||
@@ -1206,73 +1196,97 @@ function CodexSettings() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newPublicVisible}
|
||||
onChange={(e) => setNewPublicVisible(e.target.checked)}
|
||||
onChange={(event) => setNewPublicVisible(event.target.checked)}
|
||||
/>
|
||||
公开显示到未登录总览
|
||||
</label>
|
||||
<button disabled={busy} onClick={add}>
|
||||
{busy && active === 0 ? "服务启动中…" : "添加账号"}
|
||||
{busy && active === 0 ? "正在验证并添加…" : "添加账号"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="account-list">
|
||||
{xs.length === 0 && (
|
||||
<div className="panel empty account-empty">
|
||||
尚未添加 Codex 账号,请使用上方的“添加账号”创建连接。
|
||||
尚未绑定 CPA Codex 账号,请在上方输入 authIndex。
|
||||
</div>
|
||||
)}
|
||||
{xs.map((x) => (
|
||||
<section className="panel account-card" key={x.id}>
|
||||
{xs.map((account) => (
|
||||
<section className="panel account-card" key={account.id}>
|
||||
<div className="account-card-main">
|
||||
<span className={"dot " + (x.connected ? "ok" : "")} />
|
||||
<span className={"dot " + (account.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}`, decodeOK, {
|
||||
displayName: name,
|
||||
expectedKind: x.expectedKind,
|
||||
publicVisible: x.publicVisible,
|
||||
});
|
||||
void load().catch((error) =>
|
||||
setErr(toErrorMessage(error)),
|
||||
);
|
||||
}
|
||||
defaultValue={account.displayName}
|
||||
onBlur={async (event) => {
|
||||
const input = event.currentTarget;
|
||||
const displayName = input.value.trim();
|
||||
if (
|
||||
displayName &&
|
||||
displayName !== account.displayName &&
|
||||
!(await updateAccount(account, { displayName }))
|
||||
)
|
||||
input.value = account.displayName;
|
||||
}}
|
||||
/>
|
||||
<div className="account-meta">
|
||||
<span>{x.email ? maskEmail(x.email) : "尚未登录"}</span>
|
||||
<span>{planLabel(x.planType)}</span>
|
||||
<span>
|
||||
{account.email
|
||||
? maskEmail(account.email)
|
||||
: "账号信息暂不可用"}
|
||||
</span>
|
||||
<span>{planLabel(account.planType)}</span>
|
||||
<span
|
||||
className={x.validationStatus === "mismatch" ? "error" : ""}
|
||||
className={
|
||||
account.validationStatus === "mismatch" ? "error" : ""
|
||||
}
|
||||
>
|
||||
{validationLabel(x)}
|
||||
{validationLabel(account)}
|
||||
</span>
|
||||
</div>
|
||||
{x.possibleDuplicate && (
|
||||
{account.possibleDuplicate && (
|
||||
<small className="hint">
|
||||
同一邮箱已有相同类型连接,请确认没有重复授权同一工作区
|
||||
同一邮箱已有相同类型绑定,请确认 authIndex 没有指向重复凭据
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-card-actions">
|
||||
<label>
|
||||
CPA authIndex
|
||||
<input
|
||||
aria-label={`“${account.displayName}”的 CPA authIndex`}
|
||||
autoComplete="off"
|
||||
defaultValue={account.authIndex}
|
||||
onBlur={async (event) => {
|
||||
const input = event.currentTarget;
|
||||
const authIndex = input.value.trim();
|
||||
if (!authIndex || authIndex === account.authIndex) return;
|
||||
if (
|
||||
!confirm(
|
||||
`更换“${account.displayName}”的 authIndex 会清除该本地账号已有的 Token 历史、限额快照和提醒记录。继续吗?`,
|
||||
)
|
||||
) {
|
||||
input.value = account.authIndex;
|
||||
return;
|
||||
}
|
||||
if (!(await updateAccount(account, { authIndex })))
|
||||
input.value = account.authIndex;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
预期连接类型
|
||||
<select
|
||||
aria-label="预期连接类型"
|
||||
value={x.expectedKind}
|
||||
onChange={async (e) => {
|
||||
await put(`accounts/${x.id}`, decodeOK, {
|
||||
displayName: x.displayName,
|
||||
expectedKind: e.target.value,
|
||||
publicVisible: x.publicVisible,
|
||||
});
|
||||
void load().catch((error) => setErr(toErrorMessage(error)));
|
||||
}}
|
||||
value={account.expectedKind}
|
||||
onChange={(event) =>
|
||||
void updateAccount(account, {
|
||||
expectedKind: event.target
|
||||
.value as Account["expectedKind"],
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="any">不校验</option>
|
||||
<option value="personal">个人订阅</option>
|
||||
@@ -1282,73 +1296,62 @@ function CodexSettings() {
|
||||
<label className="checks">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={x.publicVisible}
|
||||
onChange={async (e) => {
|
||||
try {
|
||||
setErr("");
|
||||
await put(`accounts/${x.id}`, decodeOK, {
|
||||
displayName: x.displayName,
|
||||
expectedKind: x.expectedKind,
|
||||
publicVisible: e.target.checked,
|
||||
});
|
||||
await load();
|
||||
} catch (q) {
|
||||
setErr(toErrorMessage(q));
|
||||
}
|
||||
}}
|
||||
checked={account.publicVisible}
|
||||
onChange={(event) =>
|
||||
void updateAccount(account, {
|
||||
publicVisible: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
公开显示到未登录总览
|
||||
</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 {
|
||||
setBusy(true);
|
||||
setActive(account.id);
|
||||
setErr("");
|
||||
await post(`accounts/${x.id}/logout`, decodeOK);
|
||||
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
|
||||
await post(
|
||||
`accounts/${account.id}/sync`,
|
||||
decodeOK,
|
||||
{},
|
||||
undefined,
|
||||
60_000,
|
||||
);
|
||||
await load();
|
||||
} catch (q) {
|
||||
setErr(toErrorMessage(q));
|
||||
} catch (error) {
|
||||
setErr(toErrorMessage(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
退出
|
||||
{busy && active === account.id ? "同步中…" : "立即同步"}
|
||||
</button>
|
||||
<button
|
||||
className="icon danger"
|
||||
disabled={busy}
|
||||
title="删除连接"
|
||||
aria-label={`删除“${x.displayName}”`}
|
||||
title="删除本地绑定"
|
||||
aria-label={`删除“${account.displayName}”`}
|
||||
onClick={async () => {
|
||||
if (
|
||||
!confirm(
|
||||
`删除“${x.displayName}”及其全部凭据和历史数据?此操作无法撤销。`,
|
||||
`删除“${account.displayName}”的本地绑定及全部历史数据?此操作不会删除 CLIProxyAPI 中的凭据,且无法撤销。`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
setErr("");
|
||||
await del(`accounts/${x.id}`, decodeOK);
|
||||
if (deviceLogin?.accountId === x.id) setDeviceLogin(null);
|
||||
if (active === x.id) setActive(0);
|
||||
if (+localStorage.accountId === x.id)
|
||||
await del(`accounts/${account.id}`, decodeOK);
|
||||
if (active === account.id) setActive(0);
|
||||
if (+localStorage.accountId === account.id)
|
||||
localStorage.removeItem("accountId");
|
||||
await load();
|
||||
} catch (q) {
|
||||
setErr(toErrorMessage(q));
|
||||
} catch (error) {
|
||||
setErr(toErrorMessage(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1356,22 +1359,6 @@ function CodexSettings() {
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+14
-1
@@ -1339,12 +1339,20 @@ html[data-theme="dark"] .codebox {
|
||||
|
||||
.add-account-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.add-account-controls label {
|
||||
min-width: 250px;
|
||||
flex: 1 1 220px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.add-account-controls .checks {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.add-account-controls button {
|
||||
@@ -1419,6 +1427,11 @@ html[data-theme="dark"] .codebox {
|
||||
min-width: 285px;
|
||||
}
|
||||
|
||||
.account-card-actions > label:not(.checks) input,
|
||||
.account-card-actions > label:not(.checks) select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.account-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
+16
-14
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import { ApiError, toErrorMessage } from "./api";
|
||||
import {
|
||||
decodeAction,
|
||||
decodeAccount,
|
||||
decodeDashboard,
|
||||
decodeDeviceLogin,
|
||||
decodeGeneral,
|
||||
decodeTelegram,
|
||||
} from "./types";
|
||||
@@ -77,19 +77,21 @@ describe("API decoders", () => {
|
||||
).toThrow("theme格式无效");
|
||||
});
|
||||
|
||||
it("只接受 HTTPS 设备授权地址", () => {
|
||||
expect(() =>
|
||||
decodeDeviceLogin({
|
||||
verificationUrl: "http://example.com",
|
||||
userCode: "ABC",
|
||||
}),
|
||||
).toThrow("HTTPS");
|
||||
expect(
|
||||
decodeDeviceLogin({
|
||||
verificationUrl: "https://example.com",
|
||||
userCode: "ABC",
|
||||
}).userCode,
|
||||
).toBe("ABC");
|
||||
it("解码账号的 CPA authIndex", () => {
|
||||
const account = decodeAccount({
|
||||
id: 1,
|
||||
displayName: "主账号",
|
||||
authIndex: "index-1",
|
||||
email: null,
|
||||
planType: "plus",
|
||||
expectedKind: "personal",
|
||||
publicVisible: false,
|
||||
actualKind: "personal",
|
||||
validationStatus: "matched",
|
||||
possibleDuplicate: false,
|
||||
connected: true,
|
||||
});
|
||||
expect(account.authIndex).toBe("index-1");
|
||||
});
|
||||
|
||||
it("保留 Telegram 操作警告", () => {
|
||||
|
||||
+4
-21
@@ -17,12 +17,13 @@ export type AsyncState<T> =
|
||||
|
||||
export interface Status {
|
||||
initialized: boolean;
|
||||
appServer: boolean;
|
||||
cpa: boolean;
|
||||
version: string;
|
||||
}
|
||||
export interface Account {
|
||||
id: number;
|
||||
displayName: string;
|
||||
authIndex: string;
|
||||
email: string | null;
|
||||
planType: string | null;
|
||||
expectedKind: ExpectedKind;
|
||||
@@ -32,11 +33,6 @@ export interface Account {
|
||||
possibleDuplicate: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
export interface DeviceLogin {
|
||||
accountId: number;
|
||||
verificationUrl: string;
|
||||
userCode: string;
|
||||
}
|
||||
export interface Limit {
|
||||
limitId: string;
|
||||
limitName: string | null;
|
||||
@@ -134,7 +130,7 @@ export const decodeStatus: Decoder<Status> = (value) => {
|
||||
const x = record(value);
|
||||
return {
|
||||
initialized: boolean(x.initialized, "initialized"),
|
||||
appServer: boolean(x.appServer, "appServer"),
|
||||
cpa: boolean(x.cpa, "cpa"),
|
||||
version: string(x.version, "version"),
|
||||
};
|
||||
};
|
||||
@@ -143,6 +139,7 @@ export const decodeAccount: Decoder<Account> = (value) => {
|
||||
return {
|
||||
id: number(x.id, "id"),
|
||||
displayName: string(x.displayName, "displayName"),
|
||||
authIndex: string(x.authIndex ?? "", "authIndex"),
|
||||
email: nullableString(x.email, "email"),
|
||||
planType: nullableString(x.planType, "planType"),
|
||||
expectedKind: enumValue(
|
||||
@@ -296,20 +293,6 @@ export const decodeSMTP: Decoder<SMTPSettingsResponse> = (value) => {
|
||||
configured: boolean(x.configured, "configured"),
|
||||
};
|
||||
};
|
||||
export const decodeDeviceLogin: Decoder<Omit<DeviceLogin, "accountId">> = (
|
||||
value,
|
||||
) => {
|
||||
const x = record(value),
|
||||
verificationUrl = string(x.verificationUrl, "verificationUrl");
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(verificationUrl);
|
||||
} catch {
|
||||
throw new Error("设备授权地址无效");
|
||||
}
|
||||
if (url.protocol !== "https:") throw new Error("设备授权地址必须使用 HTTPS");
|
||||
return { verificationUrl, userCode: string(x.userCode, "userCode") };
|
||||
};
|
||||
export const decodeOK: Decoder<{ ok: boolean }> = (value) => {
|
||||
const x = record(value);
|
||||
return { ok: boolean(x.ok, "ok") };
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect, test, type Page } from "@playwright/test";
|
||||
const responses: Record<string, unknown> = {
|
||||
"system/status": {
|
||||
initialized: true,
|
||||
appServer: true,
|
||||
cpa: true,
|
||||
version: "0.3.0-beta.1",
|
||||
},
|
||||
"auth/me": { username: "admin" },
|
||||
@@ -20,6 +20,7 @@ const responses: Record<string, unknown> = {
|
||||
{
|
||||
id: 1,
|
||||
displayName: "默认账号",
|
||||
authIndex: "index-default",
|
||||
email: "test@example.com",
|
||||
planType: "plus",
|
||||
expectedKind: "personal",
|
||||
@@ -56,7 +57,7 @@ async function openSettings(page: Page, version = "0.3.0-beta.1") {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
if (key === "system/status") {
|
||||
await route.fulfill({
|
||||
json: { initialized: true, appServer: true, version },
|
||||
json: { initialized: true, cpa: true, version },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +160,7 @@ test("shows every account as a collapsible summary on the overview", async ({
|
||||
const key = new URL(request.url()).pathname.replace("/api/v1/", "");
|
||||
if (key === "system/status")
|
||||
return route.fulfill({
|
||||
json: { initialized: true, appServer: true, version: "test" },
|
||||
json: { initialized: true, cpa: true, version: "test" },
|
||||
});
|
||||
if (key === "auth/me")
|
||||
return route.fulfill({ json: { username: "admin" } });
|
||||
@@ -247,7 +248,7 @@ test("shows a read-only overview before login", async ({ page }) => {
|
||||
const key = new URL(route.request().url()).pathname.replace("/api/v1/", "");
|
||||
if (key === "system/status")
|
||||
return route.fulfill({
|
||||
json: { initialized: true, appServer: true, version: "test" },
|
||||
json: { initialized: true, cpa: true, version: "test" },
|
||||
});
|
||||
if (key === "auth/me")
|
||||
return route.fulfill({ status: 401, json: { error: "未登录" } });
|
||||
@@ -354,7 +355,7 @@ test("keeps the authenticated brand on one line with a release version", async (
|
||||
test("shows the build version before setup", async ({ page }) => {
|
||||
await page.route("**/api/v1/system/status", (route) =>
|
||||
route.fulfill({
|
||||
json: { initialized: false, appServer: false, version: "test" },
|
||||
json: { initialized: false, cpa: false, version: "test" },
|
||||
}),
|
||||
);
|
||||
await page.goto("/");
|
||||
@@ -370,7 +371,7 @@ test("shows the build version before setup", async ({ page }) => {
|
||||
test("shows the build version on login", async ({ page }) => {
|
||||
await page.route("**/api/v1/system/status", (route) =>
|
||||
route.fulfill({
|
||||
json: { initialized: true, appServer: false, version: "test" },
|
||||
json: { initialized: true, cpa: false, version: "test" },
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/v1/auth/me", (route) =>
|
||||
@@ -633,56 +634,50 @@ test("programmatic tab changes do not clamp an existing scroll position", async
|
||||
.toEqual(before.content);
|
||||
});
|
||||
|
||||
test("deleting a newly added account clears its device authorization", async ({
|
||||
test("deleting a newly added account keeps the CPA credential external", async ({
|
||||
page,
|
||||
}) => {
|
||||
let accounts: Array<(typeof responses.accounts)[number]> = [];
|
||||
let createdAuthIndex = "";
|
||||
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 body = request.postDataJSON() as { authIndex: string };
|
||||
createdAuthIndex = body.authIndex;
|
||||
const account = {
|
||||
...responses.accounts[0],
|
||||
id: 2,
|
||||
displayName: "账号 1",
|
||||
email: "",
|
||||
connected: false,
|
||||
validationStatus: "pending" as const,
|
||||
displayName: "CPA 账号",
|
||||
authIndex: body.authIndex,
|
||||
};
|
||||
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());
|
||||
page.on("dialog", (dialog) => {
|
||||
expect(dialog.message()).toContain("不会删除 CLIProxyAPI 中的凭据");
|
||||
void dialog.accept();
|
||||
});
|
||||
await page.goto("/settings");
|
||||
await page.getByRole("tab", { name: "Codex" }).click();
|
||||
await page.getByLabel("CPA authIndex", { exact: true }).fill("index-new");
|
||||
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 expect.poll(() => createdAuthIndex).toBe("index-new");
|
||||
await expect(page.getByLabel("“CPA 账号”的 CPA authIndex")).toHaveValue(
|
||||
"index-new",
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "删除“账号 1”" }).click();
|
||||
await expect(page.getByText("PLTJ-7M6I6")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "删除“CPA 账号”" }).click();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "https://auth.openai.com/codex/device" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("尚未添加 Codex 账号", { exact: false }),
|
||||
page.getByText("尚未绑定 CPA Codex 账号", { exact: false }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -690,7 +685,7 @@ test("sets public visibility when adding and editing an account", async ({
|
||||
page,
|
||||
}) => {
|
||||
let accounts: Array<(typeof responses.accounts)[number]> = [];
|
||||
let createdBody: { publicVisible?: boolean } | undefined;
|
||||
let createdBody: { authIndex?: string; publicVisible?: boolean } | undefined;
|
||||
let updatedBody: { publicVisible?: boolean } | undefined;
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const request = route.request();
|
||||
@@ -698,11 +693,15 @@ test("sets public visibility when adding and editing an account", async ({
|
||||
if (key === "accounts" && request.method() === "GET")
|
||||
return route.fulfill({ json: accounts });
|
||||
if (key === "accounts" && request.method() === "POST") {
|
||||
createdBody = request.postDataJSON() as { publicVisible?: boolean };
|
||||
createdBody = request.postDataJSON() as {
|
||||
authIndex?: string;
|
||||
publicVisible?: boolean;
|
||||
};
|
||||
const account = {
|
||||
...responses.accounts[0],
|
||||
id: 2,
|
||||
displayName: "账号 1",
|
||||
authIndex: createdBody.authIndex ?? "",
|
||||
email: "",
|
||||
connected: false,
|
||||
validationStatus: "pending" as const,
|
||||
@@ -711,13 +710,6 @@ test("sets public visibility when adding and editing an account", async ({
|
||||
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() === "PUT") {
|
||||
updatedBody = request.postDataJSON() as { publicVisible?: boolean };
|
||||
accounts = [
|
||||
@@ -738,8 +730,10 @@ test("sets public visibility when adding and editing an account", async ({
|
||||
);
|
||||
await expect(addVisibility).not.toBeChecked();
|
||||
await addVisibility.check();
|
||||
await page.getByLabel("CPA authIndex", { exact: true }).fill("index-public");
|
||||
await page.getByRole("button", { name: "添加账号" }).click();
|
||||
await expect.poll(() => createdBody?.publicVisible).toBe(true);
|
||||
expect(createdBody?.authIndex).toBe("index-public");
|
||||
|
||||
const accountCard = page.locator(".account-card");
|
||||
const accountVisibility = accountCard.locator('input[type="checkbox"]');
|
||||
@@ -751,6 +745,35 @@ test("sets public visibility when adding and editing an account", async ({
|
||||
await expect(accountVisibility).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("changing authIndex confirms local history deletion", async ({ page }) => {
|
||||
let account = { ...responses.accounts[0] };
|
||||
let updatedBody: { authIndex?: string } | undefined;
|
||||
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: [account] });
|
||||
if (key === "accounts/1" && request.method() === "PUT") {
|
||||
updatedBody = request.postDataJSON() as { authIndex?: string };
|
||||
account = { ...account, authIndex: updatedBody.authIndex ?? "" };
|
||||
return route.fulfill({ json: { ok: true } });
|
||||
}
|
||||
return route.fulfill({ json: responses[key] ?? {} });
|
||||
});
|
||||
page.on("dialog", (dialog) => {
|
||||
expect(dialog.message()).toContain("会清除该本地账号已有的 Token 历史");
|
||||
void dialog.accept();
|
||||
});
|
||||
|
||||
await page.goto("/settings");
|
||||
await page.getByRole("tab", { name: "Codex" }).click();
|
||||
const input = page.getByLabel("“默认账号”的 CPA authIndex");
|
||||
await input.fill("index-rebound");
|
||||
await input.blur();
|
||||
await expect.poll(() => updatedBody?.authIndex).toBe("index-rebound");
|
||||
await expect(input).toHaveValue("index-rebound");
|
||||
});
|
||||
|
||||
test("Codex account cards fit within the viewport", async ({ page }) => {
|
||||
await openSettings(page);
|
||||
await page.getByRole("tab", { name: "Codex" }).click();
|
||||
|
||||
Reference in New Issue
Block a user