From 84532ebe0b302d8cadbd10dd727a01628cc991fc Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:46:20 +0200 Subject: [PATCH 1/7] refactor(gui): react-doctor cleanup for Codex accounts --- gui/src/components/AddCodexAccountModal.tsx | 433 +++--------------- gui/src/components/CodexAccountPool.tsx | 327 +++---------- .../add-codex-account-pick-step.tsx | 51 +++ .../components/add-codex-account-reducer.ts | 74 +++ .../add-codex-account-waiting-step.tsx | 94 ++++ .../components/codex-account-pool-cards.tsx | 98 ++++ .../components/codex-account-pool-handlers.ts | 38 ++ .../components/codex-account-pool-helpers.tsx | 42 ++ .../codex-account-pool-main-card.tsx | 112 +++++ .../components/codex-account-pool-types.ts | 1 + .../components/codex-account-pool-utils.ts | 9 + .../components/codex-account-reset-modal.tsx | 105 +++++ .../components/codex-account-switch-modal.tsx | 68 +++ .../components/use-add-codex-account-oauth.ts | 291 ++++++++++++ 14 files changed, 1110 insertions(+), 633 deletions(-) create mode 100644 gui/src/components/add-codex-account-pick-step.tsx create mode 100644 gui/src/components/add-codex-account-reducer.ts create mode 100644 gui/src/components/add-codex-account-waiting-step.tsx create mode 100644 gui/src/components/codex-account-pool-cards.tsx create mode 100644 gui/src/components/codex-account-pool-handlers.ts create mode 100644 gui/src/components/codex-account-pool-helpers.tsx create mode 100644 gui/src/components/codex-account-pool-main-card.tsx create mode 100644 gui/src/components/codex-account-pool-types.ts create mode 100644 gui/src/components/codex-account-pool-utils.ts create mode 100644 gui/src/components/codex-account-reset-modal.tsx create mode 100644 gui/src/components/codex-account-switch-modal.tsx create mode 100644 gui/src/components/use-add-codex-account-oauth.ts diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index fa2e60d98..e5311c9f0 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -1,6 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { IconGlobe, IconLink } from "../icons"; +import { useCallback, useEffect, useReducer, useRef } from "react"; import { useT } from "../i18n"; +import { + addCodexAccountUiReducer, + initialAddCodexAccountUiState, +} from "./add-codex-account-reducer"; +import { AddCodexAccountPickStep } from "./add-codex-account-pick-step"; +import { AddCodexAccountWaitingStep } from "./add-codex-account-waiting-step"; +import { useAddCodexAccountOAuth } from "./use-add-codex-account-oauth"; export default function AddCodexAccountModal({ apiBase, onClose, onAdded, reauthAccountId, @@ -11,402 +17,73 @@ export default function AddCodexAccountModal({ reauthAccountId?: string; }) { const t = useT(); - const [step, setStep] = useState<"pick" | "oauth-waiting">(reauthAccountId ? "oauth-waiting" : "pick"); - const [id, setId] = useState(""); - const [error, setError] = useState(""); - const [authUrl, setAuthUrl] = useState(""); - const [copied, setCopied] = useState(false); - const [manualCode, setManualCode] = useState(""); - const [manualCodeState, setManualCodeState] = useState<"idle" | "submitting" | "waiting">("idle"); - const [statusNotice, setStatusNotice] = useState(""); - const [statusTone, setStatusTone] = useState<"ok" | "warn">("ok"); - const [, setPollErrorStreak] = useState(0); - const [flowId, setFlowId] = useState(null); - - const aliveRef = useRef(true); - const pollRef = useRef | null>(null); - const timeoutRef = useRef | null>(null); - const flowRef = useRef(null); - const manualCodeStateRef = useRef<"idle" | "submitting" | "waiting">("idle"); - const loginAbortRef = useRef(null); - /** Ensures reauth auto-start runs once per account id, even if startOAuth identity changes. */ - const startedReauthRef = useRef(null); - const onAddedRef = useRef(onAdded); - const onCloseRef = useRef(onClose); + const [ui, dispatch] = useReducer(addCodexAccountUiReducer, reauthAccountId, initialAddCodexAccountUiState); const previousFocusRef = useRef(null); - const dialogRef = useRef(null); + const dialogRef = useRef(null); - const manualCodeBusy = manualCodeState === "submitting"; - const manualCodeWaiting = manualCodeState === "waiting"; + const oauth = useAddCodexAccountOAuth({ apiBase, reauthAccountId, ui, dispatch, t }); + const { manualCodeBusy, manualCodeWaiting, bindCallbacks, closeModal, startOAuth, copyLoginLink, submitManualCode } = oauth; useEffect(() => { - onAddedRef.current = onAdded; - }, [onAdded]); - useEffect(() => { - onCloseRef.current = onClose; - }, [onClose]); - useEffect(() => { - manualCodeStateRef.current = manualCodeState; - }, [manualCodeState]); - - const stopPolling = useCallback(() => { - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - }, []); - - const clearManualCode = useCallback(() => { - setManualCode(""); - setManualCodeState("idle"); - setStatusNotice(""); - setStatusTone("ok"); - setPollErrorStreak(0); - }, []); - - const cancelLogin = useCallback(async () => { - clearManualCode(); - const flowId = flowRef.current; - flowRef.current = null; - setFlowId(null); - setAuthUrl(""); - stopPolling(); - loginAbortRef.current?.abort(); - loginAbortRef.current = null; - if (!flowId) return; - await fetch(`${apiBase}/api/codex-auth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ flowId }), - }).catch(() => {}); - }, [apiBase, clearManualCode, stopPolling]); - - useEffect(() => () => { - clearManualCode(); - aliveRef.current = false; - loginAbortRef.current?.abort(); - loginAbortRef.current = null; - const flowId = flowRef.current; - flowRef.current = null; - setFlowId(null); - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - // Cancel in-flight OAuth so a remounted modal cannot race a stale chatgpt scratch slot. - if (flowId) { - void fetch(`${apiBase}/api/codex-auth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ flowId }), - }).catch(() => {}); - } - }, [apiBase, clearManualCode]); - - const closeModal = useCallback(() => { - if (step === "oauth-waiting") void cancelLogin(); - onCloseRef.current(); - }, [step, cancelLogin]); - - const startOAuth = useCallback(async (requestedId?: string) => { - clearManualCode(); - flowRef.current = null; - setFlowId(null); - const controller = new AbortController(); - loginAbortRef.current?.abort(); - loginAbortRef.current = controller; - setError(""); - setStatusNotice(""); - setStatusTone("ok"); - setPollErrorStreak(0); - try { - const accountId = reauthAccountId ?? requestedId?.trim() ?? ""; - const requestLogin = () => fetch(`${apiBase}/api/codex-auth/login`, { - signal: controller.signal, - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - reauthAccountId - ? { id: reauthAccountId, reauth: true } - : (accountId ? { id: accountId } : {}), - ), - }); - let resp = await requestLogin(); - let data = await resp.json() as { url?: string; flowId?: string; error?: string; status?: string }; - if (!aliveRef.current) return; - if (resp.status === 409) { - // A newly mounted modal has no flow id for an abandoned server-side login. - // Cancel that scratch flow and retry once so interruption never leaves the - // account pool permanently stuck behind "already in progress". - await fetch(`${apiBase}/api/codex-auth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - }); - if (!aliveRef.current || controller.signal.aborted) return; - resp = await requestLogin(); - data = await resp.json() as typeof data; - if (resp.status === 409) { - setError(t("codexAuth.oauthAlreadyInProgress")); - return; - } - } - if (data.url) { - flowRef.current = data.flowId ?? null; - setFlowId(data.flowId ?? null); - setAuthUrl(data.url); - setStep("oauth-waiting"); - stopPolling(); - const fid = data.flowId ?? ""; - const reauthQuery = reauthAccountId ? "&reauth=1" : ""; - const statusUrl = fid - ? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${accountId ? `&accountId=${encodeURIComponent(accountId)}` : ""}${reauthQuery}` - : `${apiBase}/api/codex-auth/login-status`; - pollRef.current = setInterval(async () => { - try { - const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string }; - if (!aliveRef.current) return; - setPollErrorStreak(0); - if (manualCodeStateRef.current === "waiting") { - setStatusTone("ok"); - setStatusNotice(t("codexAuth.oauthCodeSubmitted")); - } else { - setStatusNotice(""); - setStatusTone("ok"); - } - if (st.status === "done") { - stopPolling(); - clearManualCode(); - flowRef.current = null; - setFlowId(null); - if (!aliveRef.current) return; - onAddedRef.current(); - onCloseRef.current(); - } else if (st.status === "error" || st.status === "expired") { - stopPolling(); - clearManualCode(); - flowRef.current = null; - setFlowId(null); - if (aliveRef.current) { - if (!reauthAccountId) setStep("pick"); - setError(st.error ?? "Login failed"); - } - } - } catch { - if (!aliveRef.current) return; - setPollErrorStreak((prev) => { - const next = prev + 1; - if (next >= 3) { - setStatusTone("warn"); - setStatusNotice(t("codexAuth.oauthStatusRetrying")); - } - return next; - }); - } - }, 2000); - timeoutRef.current = setTimeout(() => { - if (pollRef.current) { - clearManualCode(); - void cancelLogin(); - if (aliveRef.current) { - if (!reauthAccountId) setStep("pick"); - setError(t("modal.loginTimeout")); - } - } - }, 300_000); - } - if (data.error && !data.url) setError(data.error); - } catch (e) { - if (aliveRef.current && !(e instanceof Error && e.name === "AbortError")) setError(String(e)); - } - }, [apiBase, cancelLogin, clearManualCode, reauthAccountId, stopPolling, t]); + bindCallbacks(onAdded, onClose); + }, [bindCallbacks, onAdded, onClose]); - useEffect(() => { - if (!reauthAccountId) { - startedReauthRef.current = null; - return; - } - if (startedReauthRef.current === reauthAccountId) return; - startedReauthRef.current = reauthAccountId; - void startOAuth(); - }, [reauthAccountId, startOAuth]); - - const copyLoginLink = async () => { - if (!authUrl) return; - try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(authUrl); - } else { - const input = document.createElement("textarea"); - input.value = authUrl; - input.style.opacity = "0"; - input.style.position = "fixed"; - document.body.appendChild(input); - input.select(); - document.execCommand("copy"); - document.body.removeChild(input); - } - setCopied(true); - setTimeout(() => { if (aliveRef.current) setCopied(false); }, 2500); - } catch { - setError(t("codexAuth.loginLinkCopyFailed")); - } - }; - - const submitManualCode = useCallback(async () => { - /* [Decision Log] - - 목적과 의도: 수동 URL/코드 제출 뒤 최종 로그인 완료까지의 상태를 사용자가 구분할 수 있게 한다. - - 기존 구현 및 제약 조건: 제출 버튼은 요청 직후 원래 상태로 돌아왔고 입력도 즉시 비워져서, "제출됨/대기 중/네트워크 재시도"를 구분할 수 없었다. - - 검토한 주요 대안: (1) 스피너만 유지 — 제출 전/후 차이가 드러나지 않는다. (2) 서버 상태 필드를 늘리기 — UI 피드백 문제에 비해 범위가 커진다. (3) 클라이언트에서 제출/대기 상태와 poll warning을 별도 관리 — 현재 API를 유지하면서 UX를 분리할 수 있다. - - 선택한 방식: idle/submitting/waiting 상태를 두고, 성공 제출 후에는 입력을 잠그고 aria-live 상태 안내를 노출한다. - - 다른 대안 대신 이 방식을 선택한 이유: 백엔드 계약을 넓히지 않고도 중복 제출 방지, 진행 상황, 반복 poll 오류 안내를 한 번에 해결한다. - - 장점, 단점 및 영향: 장점은 사용자 피드백과 접근성이 즉시 개선된다는 점이다. 단점은 클라이언트 상태가 조금 늘어난다는 점이며, clearManualCode가 reset 경계가 된다. - */ - const flowId = flowRef.current; - const input = manualCode.trim(); - if (!flowId || !input || manualCodeBusy || manualCodeWaiting) return; - setManualCodeState("submitting"); - setStatusNotice(""); - setStatusTone("ok"); - try { - const resp = await fetch(`${apiBase}/api/codex-auth/login/code`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ flowId, input }), - }); - const data = await resp.json().catch(() => ({})) as { error?: string }; - if (!aliveRef.current) return; - if (!resp.ok) { - setError(t("prov.pasteFail", { error: data.error ?? resp.statusText })); - setManualCodeState("idle"); - return; - } - setError(""); - setManualCode(""); - setManualCodeState("waiting"); - setStatusTone("ok"); - setStatusNotice(t("codexAuth.oauthCodeSubmitted")); - setPollErrorStreak(0); - } catch { - if (aliveRef.current) { - setError(t("modal.networkError")); - setManualCodeState("idle"); - } - } - }, [apiBase, manualCode, manualCodeBusy, manualCodeWaiting, t]); - - // Focus-trap: focus first interactive element on mount, restore on unmount. useEffect(() => { previousFocusRef.current = document.activeElement as HTMLElement | null; const dialog = dialogRef.current; - if (dialog) { - const focusable = dialog.querySelector( - "input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])" - ); - if (focusable) focusable.focus(); - } + if (dialog && !dialog.open) dialog.showModal(); + const focusable = dialog?.querySelector( + "input:not([disabled]), button:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])", + ); + if (focusable) focusable.focus(); return () => { previousFocusRef.current?.focus(); }; }, []); - useEffect(() => { - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeModal(); }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + closeModal(); }, [closeModal]); const dialogLabel = reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.addTitle"); return ( -
-
e.stopPropagation()} style={{ maxWidth: 440 }}> - {step === "pick" && ( - <> -

{t("codexAuth.addTitle")}

-

{t("codexAuth.addPickDesc")}

- - - setId(e.target.value)} - style={{ marginBottom: 12 }} - /> - - - - {error &&
{error}
} - - - + +
+ {ui.step === "pick" && ( + dispatch({ type: "set-id", id: value })} + onStartOAuth={() => { void startOAuth(ui.id); }} + onClose={closeModal} + /> )} - - {step === "oauth-waiting" && ( - <> -

{reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.oauthLogin")}

-

{t("codexAuth.oauthWaiting")}

- -
-
{t("prov.pasteRedirectHint")}
-
- setManualCode(e.target.value)} - onKeyDown={e => { - if (e.key === "Enter") { - e.preventDefault(); - void submitManualCode(); - } - }} - placeholder={t("prov.pasteRedirect")} - aria-label={t("prov.pasteRedirect")} - disabled={manualCodeBusy || manualCodeWaiting} - className="input text-label" - style={{ flex: 1 }} - /> - -
-
- {statusNotice && ( -
- {statusNotice} -
- )} - {error &&
{error}
} -
- -
- - + {ui.step === "oauth-waiting" && ( + { void copyLoginLink(); }} + onManualCodeChange={value => dispatch({ type: "set-manual-code", manualCode: value })} + onSubmitManualCode={() => { void submitManualCode(); }} + onClose={closeModal} + /> )}
-
+ ); } diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 1a98b89c2..9afa75f2b 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -1,14 +1,20 @@ import { useCallback, useEffect, useState } from "react"; -import { useT, type TFn } from "../i18n"; -import { IconLock, IconPlus, IconX, IconAlert, IconRefresh, IconTicket } from "../icons"; +import { useT } from "../i18n"; +import { IconPlus } from "../icons"; import { Notice, EmptyState } from "../ui"; import AddCodexAccountModal from "./AddCodexAccountModal"; -import { useCodexAccountPool, type CodexAccountPoolController, type CodexAccountEntry } from "../hooks/useCodexAccountPool"; -import QuotaBars from "./QuotaBars"; +import { useCodexAccountPool, type CodexAccountPoolController } from "../hooks/useCodexAccountPool"; import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; import CodexAutoSwitchSetting from "./CodexAutoSwitchSetting"; import { useCodexAutoSwitch } from "../hooks/useCodexAutoSwitch"; +import { readJsonIfOk } from "../fetch-json"; +import { CodexAccountPoolCards, CodexAccountPoolReauthBanner } from "./codex-account-pool-cards"; +import { CodexAccountSwitchModal } from "./codex-account-switch-modal"; +import { CodexAccountResetModal } from "./codex-account-reset-modal"; +import { CodexAccountPoolLoadStates, CodexAccountPoolMainCard, CodexAccountPoolPageHead } from "./codex-account-pool-main-card"; +import { redeemResetCredit } from "./codex-account-pool-handlers"; +import type { CodexAccountEntry } from "./codex-account-pool-types"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; @@ -168,8 +174,8 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setCreditDetailsLoading(true); try { const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits?accountId=${encodeURIComponent(account.id)}`); - if (resp.ok) { - const data = (await resp.json()) as { credits?: { granted_at: string; expires_at: string }[] }; + const data = await readJsonIfOk<{ credits?: { granted_at: string; expires_at: string }[] }>(resp); + if (data) { const sorted = (data.credits ?? []).sort((a, b) => new Date(a.granted_at).getTime() - new Date(b.granted_at).getTime() ); @@ -182,29 +188,15 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const handleRedeem = async (accountId: string) => { setRedeeming(true); try { - const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits/consume`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ accountId }), - }); - if (!resp.ok) { alert(t("codexAuth.resetError")); return; } - const result = (await resp.json()) as { code: string }; - if (result.code === "reset" || result.code === "already_redeemed") { - const prevCredits = resetPopup?.quota?.resetCredits ?? 1; + const result = await redeemResetCredit(apiBase, accountId, t, resetPopup, load); + if (result.close) { setResetPopup(null); setResetConfirm(false); - await load(true); - setToast(t("codexAuth.resetSuccess", { remaining: String(Math.max(0, prevCredits - 1)) })); + } + if (result.toast) { + setToast(result.toast); setTimeout(() => setToast(""), 5000); - } else if (result.code === "nothing_to_reset") { - alert(t("codexAuth.resetNothingToReset")); - } else if (result.code === "no_credit") { - alert(t("codexAuth.resetNoCredit")); - } else { - alert(t("codexAuth.resetError")); } - } catch { - alert(t("codexAuth.resetError")); } finally { setRedeeming(false); } @@ -212,133 +204,66 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const main = accounts.find(a => a.isMain); const pool = accounts.filter(a => !a.isMain); - const isNext = (id: string) => activeId === id; - // Main is the active/next account when no pool account is selected (legacy null) or when - // it is explicitly set to the rotation id "__main__". const isMainActive = !activeId || activeId === "__main__"; - const mainSwitchEntry: CodexAccountEntry = { - id: "__main__", - email: main?.email ?? "Codex App", - plan: main?.plan, - isMain: true, - hasCredential: true, - quota: main?.quota ?? null, - }; const switchActionLabel = t(accountModeState === "direct" ? "codexAuth.prepareForPool" : "codexAuth.setAsNext"); return (
- {!embedded && ( -
-

{t("nav.codexAuth")}

- -
- )} + { void refreshQuotas(); }} + /> {toast && {toast}} - {loadState === "loading" && accounts.length === 0 && ( -
{t("pws.accountsLoading")}
- )} - {loadState === "error" && ( -
- {t("codexAuth.loadFailed")} - -
- )} + { void load(); }} + /> {banner} -
-
- - {t("codexAuth.mainAccount")} - - {main && openResetPopup({ ...main, id: "__main__" } as CodexAccountEntry)} />} - {main?.needsReauth && {t("codexAuth.needsReauth")}} - - {isMainActive - ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") - : t("codexAuth.current")} - - - {!isMainActive && ( - - )} - {t("codexAuth.appLogin")} -
-
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
- {main?.needsReauth - ?
{t("codexAuth.mainTokenExpired")}
- : main?.quota && } -
+
{t("codexAuth.accountPool")}
-
{activePoolAccount?.needsReauth && ( -
- {t("codexAuth.tokenExpired")} - -
+ openReauth(activePoolAccount.id)} /> )} {pool.length === 0 && } - {pool.map(a => ( -
-
- - {a.alias ?? a.email} - - {a.plan && {a.plan}} - openResetPopup(a)} /> - {a.needsReauth && {t("codexAuth.needsReauth")}} - {isNext(a.id) && !a.needsReauth && ( - - {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} - - )} - - {!isNext(a.id) && !a.needsReauth && ( - - )} - {a.needsReauth && ( - - )} - - -
-
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {a.id}
- {a.needsReauth - ?
{t("codexAuth.tokenExpired")}
- : } -
- ))} + {confirm && ( -
setConfirm(null)}> -
e.stopPropagation()}> -

{accountModeState === "direct" - ? t("codexAuth.preparePoolTitle") - : confirm.id === "__main__" ? t("codexAuth.switchBack") : t("codexAuth.switchTitle")}

-

- {accountModeState === "direct" - ? t("codexAuth.preparePoolDesc") - : confirm.id === "__main__" ? t("codexAuth.switchBackDesc") : t("codexAuth.switchDesc")} -

-
- {confirm.id === "__main__" ? main?.email : confirm.email} - {confirm.plan && {confirm.plan}} -
- {confirm.id !== "__main__" && ( -
{t("codexAuth.cacheWarning")}
- )} -
- - -
-
-
+ setConfirm(null)} + onConfirm={() => { void setActive(confirm.id === "__main__" ? "__main__" : confirm.id); }} + /> )} {resetPopup && ( -
{ setResetPopup(null); setResetConfirm(false); setCreditDetails(null); }}> -
e.stopPropagation()}> - {!resetConfirm ? ( - <> -

{t("codexAuth.resetCreditsTitle")}

-
{resetPopup.email}{resetPopup.plan ? ` · ${resetPopup.plan}` : ""}
-
- {(resetPopup.quota?.resetCredits ?? 0) > 0 ? ( - <> -

{t("codexAuth.resetCreditsAvailable", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

- {creditDetailsLoading &&

{t("common.loading")}

} - {creditDetails && creditDetails.length > 0 && ( -
- {creditDetails.map((c, i) => ( - - ))} -
- )} - -

{t("codexAuth.fifoNote")}

- - ) : ( - <> -

{t("codexAuth.noResetCredits")}

-

{t("codexAuth.earnCreditsHint")}

- - )} -
- - ) : ( - <> -
-
-

{t("codexAuth.confirmResetTitle")}

-

{t("codexAuth.confirmResetDesc", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

- {creditDetails && creditDetails[0] && ( -

- {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at) })} -

- )} -

{t("codexAuth.irreversible")}

-
-
- - -
- - )} -
-
+ { setResetPopup(null); setResetConfirm(false); setCreditDetails(null); }} + onShowConfirm={() => setResetConfirm(true)} + onCancelConfirm={() => setResetConfirm(false)} + onRedeem={() => { void handleRedeem(resetPopup.id); }} + /> )} {showAdd && ( @@ -454,50 +318,3 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
); } - -function formatCreditDate(iso: string): string { - const d = new Date(iso); - return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" }).format(d); -} - -function daysUntil(iso: string): number { - return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000)); -} - -function CreditItem({ index, grantedAt, expiresAt, isNext, t }: { - index: number; grantedAt: string; expiresAt: string; isNext: boolean; t: TFn; -}) { - const days = daysUntil(expiresAt); - const urgent = days <= 7; - return ( -
-
- - - {isNext ? t("codexAuth.creditNext") : t("codexAuth.creditLabel", { n: String(index + 1) })} - - {isNext && {t("codexAuth.creditNextBadge")}} -
-
- {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt) })} - {t("codexAuth.creditExpires", { date: formatCreditDate(expiresAt), days: String(days) })} -
-
- ); -} - -function TicketBadge({ account, onClick, t }: { account: CodexAccountEntry; onClick: () => void; t: TFn }) { - const credits = account.quota?.resetCredits; - if (credits === undefined) return null; - const hasCredits = typeof credits === "number" && credits > 0; - return ( - - ); -} diff --git a/gui/src/components/add-codex-account-pick-step.tsx b/gui/src/components/add-codex-account-pick-step.tsx new file mode 100644 index 000000000..92b605ce5 --- /dev/null +++ b/gui/src/components/add-codex-account-pick-step.tsx @@ -0,0 +1,51 @@ +import { IconGlobe } from "../icons"; +import { useT } from "../i18n"; + +export function AddCodexAccountPickStep({ + id, + error, + onIdChange, + onStartOAuth, + onClose, +}: { + id: string; + error: string; + onIdChange: (value: string) => void; + onStartOAuth: () => void; + onClose: () => void; +}) { + const t = useT(); + + return ( + <> +

{t("codexAuth.addTitle")}

+

{t("codexAuth.addPickDesc")}

+ + + onIdChange(e.target.value)} + style={{ marginBottom: 12 }} + /> + + + + {error &&
{error}
} + + + + ); +} diff --git a/gui/src/components/add-codex-account-reducer.ts b/gui/src/components/add-codex-account-reducer.ts new file mode 100644 index 000000000..242dc0010 --- /dev/null +++ b/gui/src/components/add-codex-account-reducer.ts @@ -0,0 +1,74 @@ +export type AddCodexAccountStep = "pick" | "oauth-waiting"; +export type ManualCodeState = "idle" | "submitting" | "waiting"; +export type StatusTone = "ok" | "warn"; + +export interface AddCodexAccountUiState { + step: AddCodexAccountStep; + id: string; + error: string; + authUrl: string; + copied: boolean; + manualCode: string; + manualCodeState: ManualCodeState; + statusNotice: string; + statusTone: StatusTone; + flowId: string | null; +} + +export const initialAddCodexAccountUiState = (reauthAccountId?: string): AddCodexAccountUiState => ({ + step: reauthAccountId ? "oauth-waiting" : "pick", + id: "", + error: "", + authUrl: "", + copied: false, + manualCode: "", + manualCodeState: "idle", + statusNotice: "", + statusTone: "ok", + flowId: null, +}); + +export type AddCodexAccountUiAction = + | { type: "set-step"; step: AddCodexAccountStep } + | { type: "set-id"; id: string } + | { type: "set-error"; error: string } + | { type: "set-auth-url"; authUrl: string } + | { type: "set-copied"; copied: boolean } + | { type: "set-manual-code"; manualCode: string } + | { type: "set-manual-code-state"; manualCodeState: ManualCodeState } + | { type: "set-status-notice"; statusNotice: string; statusTone?: StatusTone } + | { type: "set-flow-id"; flowId: string | null } + | { type: "clear-manual-code" } + | { type: "reset-oauth-start" } + | { type: "oauth-code-submitted" }; + +export function addCodexAccountUiReducer(state: AddCodexAccountUiState, action: AddCodexAccountUiAction): AddCodexAccountUiState { + switch (action.type) { + case "set-step": + return { ...state, step: action.step }; + case "set-id": + return { ...state, id: action.id }; + case "set-error": + return { ...state, error: action.error }; + case "set-auth-url": + return { ...state, authUrl: action.authUrl }; + case "set-copied": + return { ...state, copied: action.copied }; + case "set-manual-code": + return { ...state, manualCode: action.manualCode }; + case "set-manual-code-state": + return { ...state, manualCodeState: action.manualCodeState }; + case "set-status-notice": + return { ...state, statusNotice: action.statusNotice, statusTone: action.statusTone ?? state.statusTone }; + case "set-flow-id": + return { ...state, flowId: action.flowId }; + case "clear-manual-code": + return { ...state, manualCode: "", manualCodeState: "idle", statusNotice: "", statusTone: "ok" }; + case "reset-oauth-start": + return { ...state, error: "", statusNotice: "", statusTone: "ok", flowId: null }; + case "oauth-code-submitted": + return { ...state, error: "", manualCode: "", manualCodeState: "waiting", statusTone: "ok", statusNotice: "" }; + default: + return state; + } +} diff --git a/gui/src/components/add-codex-account-waiting-step.tsx b/gui/src/components/add-codex-account-waiting-step.tsx new file mode 100644 index 000000000..72eff5332 --- /dev/null +++ b/gui/src/components/add-codex-account-waiting-step.tsx @@ -0,0 +1,94 @@ +import { IconLink } from "../icons"; +import { useT } from "../i18n"; + +export function AddCodexAccountWaitingStep({ + reauthAccountId, + authUrl, + copied, + manualCode, + manualCodeBusy, + manualCodeWaiting, + statusNotice, + statusTone, + flowId, + error, + onCopyLoginLink, + onManualCodeChange, + onSubmitManualCode, + onClose, +}: { + reauthAccountId?: string; + authUrl: string; + copied: boolean; + manualCode: string; + manualCodeBusy: boolean; + manualCodeWaiting: boolean; + statusNotice: string; + statusTone: "ok" | "warn"; + flowId: string | null; + error: string; + onCopyLoginLink: () => void; + onManualCodeChange: (value: string) => void; + onSubmitManualCode: () => void; + onClose: () => void; +}) { + const t = useT(); + + return ( + <> +

{reauthAccountId ? t("codexAuth.reauthenticate") : t("codexAuth.oauthLogin")}

+

{t("codexAuth.oauthWaiting")}

+ +
+
{t("prov.pasteRedirectHint")}
+
+ onManualCodeChange(e.target.value)} + onKeyDown={e => { + if (e.key === "Enter") { + e.preventDefault(); + onSubmitManualCode(); + } + }} + placeholder={t("prov.pasteRedirect")} + aria-label={t("prov.pasteRedirect")} + disabled={manualCodeBusy || manualCodeWaiting} + className="input text-label" + style={{ flex: 1 }} + /> + +
+
+ {statusNotice && ( +
+ {statusNotice} +
+ )} + {error &&
{error}
} +
+ +
+ + + ); +} diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx new file mode 100644 index 000000000..3517428e9 --- /dev/null +++ b/gui/src/components/codex-account-pool-cards.tsx @@ -0,0 +1,98 @@ +import { useT } from "../i18n"; +import { IconAlert, IconX } from "../icons"; +import type { CodexAccountEntry } from "./codex-account-pool-types"; +import type { CodexAccountModeState } from "../codex-multi-state"; +import QuotaBars from "./QuotaBars"; +import { CodexTicketBadge } from "./codex-account-pool-helpers"; + +export function CodexAccountPoolCards({ + pool, + activeId, + accountModeState, + switchActionLabel, + threshold, + onOpenReset, + onSwitch, + onReauth, + onEditAlias, + onRemove, +}: { + pool: CodexAccountEntry[]; + activeId: string | null; + accountModeState: CodexAccountModeState | null; + switchActionLabel: string; + threshold: number; + onOpenReset: (account: CodexAccountEntry) => void; + onSwitch: (account: CodexAccountEntry) => void; + onReauth: (id: string) => void; + onEditAlias: (account: CodexAccountEntry) => void; + onRemove: (id: string) => void; +}) { + const t = useT(); + const isNext = (id: string) => activeId === id; + + return ( + <> + {pool.map(a => ( +
+
+ + {a.alias ?? a.email} + + {a.plan && {a.plan}} + onOpenReset(a)} /> + {a.needsReauth && {t("codexAuth.needsReauth")}} + {isNext(a.id) && !a.needsReauth && ( + + {t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")} + + )} + + {!isNext(a.id) && !a.needsReauth && ( + + )} + {a.needsReauth && ( + + )} + + +
+
{a.email}{a.plan ? ` · ${a.plan}` : ""} · {t("prov.accountId")}: {a.id}
+ {a.needsReauth + ?
{t("codexAuth.tokenExpired")}
+ : } +
+ ))} + + ); +} + +export function CodexAccountPoolReauthBanner({ + onReauth, +}: { + onReauth: () => void; +}) { + const t = useT(); + return ( +
+ {t("codexAuth.tokenExpired")} + +
+ ); +} diff --git a/gui/src/components/codex-account-pool-handlers.ts b/gui/src/components/codex-account-pool-handlers.ts new file mode 100644 index 000000000..b6e02ad5d --- /dev/null +++ b/gui/src/components/codex-account-pool-handlers.ts @@ -0,0 +1,38 @@ +import type { TFn } from "../i18n"; +import { readJsonIfOk } from "../fetch-json"; +import type { CodexAccountEntry } from "./codex-account-pool-types"; + +export async function redeemResetCredit( + apiBase: string, + accountId: string, + t: TFn, + resetPopup: CodexAccountEntry | null, + load: (refresh?: boolean) => Promise, +): Promise<{ ok: boolean; toast?: string; close?: boolean }> { + try { + const resp = await fetch(`${apiBase}/api/codex-auth/reset-credits/consume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId }), + }); + if (!resp.ok) { alert(t("codexAuth.resetError")); return { ok: false }; } + const result = await readJsonIfOk<{ code: string }>(resp); + if (!result) { alert(t("codexAuth.resetError")); return { ok: false }; } + if (result.code === "reset" || result.code === "already_redeemed") { + const prevCredits = resetPopup?.quota?.resetCredits ?? 1; + await load(true); + return { + ok: true, + close: true, + toast: t("codexAuth.resetSuccess", { remaining: String(Math.max(0, prevCredits - 1)) }), + }; + } + if (result.code === "nothing_to_reset") alert(t("codexAuth.resetNothingToReset")); + else if (result.code === "no_credit") alert(t("codexAuth.resetNoCredit")); + else alert(t("codexAuth.resetError")); + return { ok: false }; + } catch { + alert(t("codexAuth.resetError")); + return { ok: false }; + } +} diff --git a/gui/src/components/codex-account-pool-helpers.tsx b/gui/src/components/codex-account-pool-helpers.tsx new file mode 100644 index 000000000..92e50f6c8 --- /dev/null +++ b/gui/src/components/codex-account-pool-helpers.tsx @@ -0,0 +1,42 @@ +import type { TFn } from "../i18n"; +import { IconTicket } from "../icons"; +import type { CodexAccountEntry } from "./codex-account-pool-types"; +import { daysUntil, formatCreditDate } from "./codex-account-pool-utils"; + +export function CodexCreditItem({ index, grantedAt, expiresAt, isNext, t }: { + index: number; grantedAt: string; expiresAt: string; isNext: boolean; t: TFn; +}) { + const days = daysUntil(expiresAt); + const urgent = days <= 7; + return ( +
+
+ + + {isNext ? t("codexAuth.creditNext") : t("codexAuth.creditLabel", { n: String(index + 1) })} + + {isNext && {t("codexAuth.creditNextBadge")}} +
+
+ {t("codexAuth.creditGranted", { date: formatCreditDate(grantedAt) })} + {t("codexAuth.creditExpires", { date: formatCreditDate(expiresAt), days: String(days) })} +
+
+ ); +} + +export function CodexTicketBadge({ account, onClick, t }: { account: CodexAccountEntry; onClick: () => void; t: TFn }) { + const credits = account.quota?.resetCredits; + if (credits === undefined) return null; + const hasCredits = typeof credits === "number" && credits > 0; + return ( + + ); +} diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx new file mode 100644 index 000000000..25afc132f --- /dev/null +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -0,0 +1,112 @@ +import type { ReactNode } from "react"; +import { IconLock, IconRefresh } from "../icons"; +import QuotaBars from "./QuotaBars"; +import { CodexTicketBadge } from "./codex-account-pool-helpers"; +import type { CodexAccountEntry } from "./codex-account-pool-types"; +import type { CodexAccountModeState } from "../codex-multi-state"; +import type { TFn } from "../i18n"; + +export function CodexAccountPoolMainCard({ + t, + main, + isMainActive, + accountModeState, + threshold, + switchActionLabel, + onSwitch, + onOpenReset, +}: { + t: TFn; + main: CodexAccountEntry | undefined; + isMainActive: boolean; + accountModeState: CodexAccountModeState | null; + threshold: number; + switchActionLabel: string; + onSwitch: (entry: CodexAccountEntry) => void; + onOpenReset: (account: CodexAccountEntry) => void; +}) { + const mainSwitchEntry: CodexAccountEntry = { + id: "__main__", + email: main?.email ?? "Codex App", + plan: main?.plan, + isMain: true, + hasCredential: true, + quota: main?.quota ?? null, + }; + + return ( +
+
+ + {t("codexAuth.mainAccount")} + + {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />} + {main?.needsReauth && {t("codexAuth.needsReauth")}} + + {isMainActive + ? t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession") + : t("codexAuth.current")} + + + {!isMainActive && ( + + )} + {t("codexAuth.appLogin")} +
+
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
+ {main?.needsReauth + ?
{t("codexAuth.mainTokenExpired")}
+ : main?.quota && } +
+ ); +} + +export function CodexAccountPoolPageHead({ + t, + embedded, + refreshingQuota, + onRefresh, +}: { + t: TFn; + embedded: boolean; + refreshingQuota: boolean; + onRefresh: () => void; +}) { + if (embedded) return null; + return ( +
+

{t("nav.codexAuth")}

+ +
+ ); +} + +export function CodexAccountPoolLoadStates({ + t, + loadState, + accountsCount, + onRetry, +}: { + t: TFn; + loadState: "loading" | "ready" | "error"; + accountsCount: number; + onRetry: () => void; +}): ReactNode { + return ( + <> + {loadState === "loading" && accountsCount === 0 && ( +
{t("pws.accountsLoading")}
+ )} + {loadState === "error" && ( +
+ {t("codexAuth.loadFailed")} + +
+ )} + + ); +} diff --git a/gui/src/components/codex-account-pool-types.ts b/gui/src/components/codex-account-pool-types.ts new file mode 100644 index 000000000..679350fa2 --- /dev/null +++ b/gui/src/components/codex-account-pool-types.ts @@ -0,0 +1 @@ +export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; diff --git a/gui/src/components/codex-account-pool-utils.ts b/gui/src/components/codex-account-pool-utils.ts new file mode 100644 index 000000000..52d091eba --- /dev/null +++ b/gui/src/components/codex-account-pool-utils.ts @@ -0,0 +1,9 @@ +import { formatCreditDate as formatCreditDateIntl } from "../intl-formatters"; + +export function formatCreditDate(iso: string): string { + return formatCreditDateIntl(iso); +} + +export function daysUntil(iso: string): number { + return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / 86_400_000)); +} diff --git a/gui/src/components/codex-account-reset-modal.tsx b/gui/src/components/codex-account-reset-modal.tsx new file mode 100644 index 000000000..9251335ea --- /dev/null +++ b/gui/src/components/codex-account-reset-modal.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useT } from "../i18n"; +import { IconAlert, IconTicket } from "../icons"; +import type { CodexAccountEntry } from "./codex-account-pool-types"; +import { CodexCreditItem } from "./codex-account-pool-helpers"; +import { formatCreditDate } from "./codex-account-pool-utils"; + +export function CodexAccountResetModal({ + resetPopup, + resetConfirm, + creditDetails, + creditDetailsLoading, + redeeming, + onClose, + onShowConfirm, + onCancelConfirm, + onRedeem, +}: { + resetPopup: CodexAccountEntry; + resetConfirm: boolean; + creditDetails: { granted_at: string; expires_at: string }[] | null; + creditDetailsLoading: boolean; + redeeming: boolean; + onClose: () => void; + onShowConfirm: () => void; + onCancelConfirm: () => void; + onRedeem: () => void; +}) { + const t = useT(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + onClose(); + }, [onClose]); + + return ( + +
e.stopPropagation()} role="document"> + {!resetConfirm ? ( + <> +

{t("codexAuth.resetCreditsTitle")}

+
{resetPopup.email}{resetPopup.plan ? ` · ${resetPopup.plan}` : ""}
+
+ {(resetPopup.quota?.resetCredits ?? 0) > 0 ? ( + <> +

{t("codexAuth.resetCreditsAvailable", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

+ {creditDetailsLoading &&

{t("common.loading")}

} + {creditDetails && creditDetails.length > 0 && ( +
+ {creditDetails.map((c, i) => ( + + ))} +
+ )} + +

{t("codexAuth.fifoNote")}

+ + ) : ( + <> +

{t("codexAuth.noResetCredits")}

+

{t("codexAuth.earnCreditsHint")}

+ + )} +
+ + ) : ( + <> +
+
+

{t("codexAuth.confirmResetTitle")}

+

{t("codexAuth.confirmResetDesc", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

+ {creditDetails && creditDetails[0] && ( +

+ {t("codexAuth.confirmWhichCredit", { date: formatCreditDate(creditDetails[0].granted_at) })} +

+ )} +

{t("codexAuth.irreversible")}

+
+
+ + +
+ + )} +
+
+ ); +} diff --git a/gui/src/components/codex-account-switch-modal.tsx b/gui/src/components/codex-account-switch-modal.tsx new file mode 100644 index 000000000..cea7ba5ef --- /dev/null +++ b/gui/src/components/codex-account-switch-modal.tsx @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useT } from "../i18n"; +import { IconAlert } from "../icons"; +import type { CodexAccountEntry } from "./codex-account-pool-types"; +import type { CodexAccountModeState } from "../codex-multi-state"; + +export function CodexAccountSwitchModal({ + confirm, + mainEmail, + accountModeState, + switchingId, + onCancel, + onConfirm, +}: { + confirm: CodexAccountEntry; + mainEmail?: string; + accountModeState: CodexAccountModeState | null; + switchingId: string | null; + onCancel: () => void; + onConfirm: () => void; +}) { + const t = useT(); + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + }, []); + + const handleCancel = useCallback((e: React.SyntheticEvent) => { + e.preventDefault(); + onCancel(); + }, [onCancel]); + + return ( + +
e.stopPropagation()} role="document"> +

{accountModeState === "direct" + ? t("codexAuth.preparePoolTitle") + : confirm.id === "__main__" ? t("codexAuth.switchBack") : t("codexAuth.switchTitle")}

+

+ {accountModeState === "direct" + ? t("codexAuth.preparePoolDesc") + : confirm.id === "__main__" ? t("codexAuth.switchBackDesc") : t("codexAuth.switchDesc")} +

+
+ {confirm.id === "__main__" ? mainEmail : confirm.email} + {confirm.plan && {confirm.plan}} +
+ {confirm.id !== "__main__" && ( +
{t("codexAuth.cacheWarning")}
+ )} +
+ + +
+
+
+ ); +} diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts new file mode 100644 index 000000000..955e7e7fb --- /dev/null +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { Dispatch } from "react"; +import type { AddCodexAccountUiAction } from "./add-codex-account-reducer"; +import type { TFn } from "../i18n"; +import { readJsonIfOk } from "../fetch-json"; + +export function useAddCodexAccountOAuth({ + apiBase, + reauthAccountId, + ui, + dispatch, + t, +}: { + apiBase: string; + reauthAccountId?: string; + ui: { + step: string; + manualCodeState: string; + manualCode: string; + authUrl: string; + flowId: string | null; + }; + dispatch: Dispatch; + t: TFn; +}) { + const aliveRef = useRef(true); + const pollErrorStreakRef = useRef(0); + const pollRef = useRef | null>(null); + const timeoutRef = useRef | null>(null); + const flowRef = useRef(null); + const manualCodeStateRef = useRef(ui.manualCodeState); + const loginAbortRef = useRef(null); + const startedReauthRef = useRef(null); + const onAddedRef = useRef<() => void>(() => {}); + const onCloseRef = useRef<() => void>(() => {}); + + const manualCodeBusy = ui.manualCodeState === "submitting"; + const manualCodeWaiting = ui.manualCodeState === "waiting"; + + useEffect(() => { + manualCodeStateRef.current = ui.manualCodeState; + }, [ui.manualCodeState]); + useEffect(() => { + flowRef.current = ui.flowId; + }, [ui.flowId]); + + const stopPolling = useCallback(() => { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + }, []); + + const clearManualCode = useCallback(() => { + dispatch({ type: "clear-manual-code" }); + pollErrorStreakRef.current = 0; + }, [dispatch]); + + const cancelLogin = useCallback(async () => { + clearManualCode(); + const flowId = flowRef.current; + flowRef.current = null; + dispatch({ type: "set-flow-id", flowId: null }); + dispatch({ type: "set-auth-url", authUrl: "" }); + stopPolling(); + loginAbortRef.current?.abort(); + loginAbortRef.current = null; + if (!flowId) return; + await fetch(`${apiBase}/api/codex-auth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ flowId }), + }).catch(() => {}); + }, [apiBase, clearManualCode, dispatch, stopPolling]); + + useEffect(() => () => { + clearManualCode(); + aliveRef.current = false; + loginAbortRef.current?.abort(); + loginAbortRef.current = null; + const flowId = flowRef.current; + flowRef.current = null; + dispatch({ type: "set-flow-id", flowId: null }); + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + if (flowId) { + void fetch(`${apiBase}/api/codex-auth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ flowId }), + }).catch(() => {}); + } + }, [apiBase, clearManualCode, dispatch]); + + const bindCallbacks = useCallback((onAdded: () => void, onClose: () => void) => { + onAddedRef.current = onAdded; + onCloseRef.current = onClose; + }, []); + + const closeModal = useCallback(() => { + if (ui.step === "oauth-waiting") void cancelLogin(); + onCloseRef.current(); + }, [ui.step, cancelLogin]); + + const startOAuth = useCallback(async (requestedId?: string) => { + clearManualCode(); + flowRef.current = null; + dispatch({ type: "set-flow-id", flowId: null }); + const controller = new AbortController(); + loginAbortRef.current?.abort(); + loginAbortRef.current = controller; + dispatch({ type: "reset-oauth-start" }); + pollErrorStreakRef.current = 0; + try { + const accountId = reauthAccountId ?? requestedId?.trim() ?? ""; + const requestLogin = () => fetch(`${apiBase}/api/codex-auth/login`, { + signal: controller.signal, + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + reauthAccountId + ? { id: reauthAccountId, reauth: true } + : (accountId ? { id: accountId } : {}), + ), + }); + type LoginResponse = { url?: string; flowId?: string; error?: string; status?: string }; + let resp = await requestLogin(); + let data = await readJsonIfOk(resp); + if (!aliveRef.current) return; + if (resp.status === 409) { + await fetch(`${apiBase}/api/codex-auth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!aliveRef.current || controller.signal.aborted) return; + resp = await requestLogin(); + data = await readJsonIfOk(resp); + if (resp.status === 409) { + dispatch({ type: "set-error", error: t("codexAuth.oauthAlreadyInProgress") }); + return; + } + } + if (!data) { + dispatch({ type: "set-error", error: t("modal.networkError") }); + return; + } + if (data.url) { + flowRef.current = data.flowId ?? null; + dispatch({ type: "set-flow-id", flowId: data.flowId ?? null }); + dispatch({ type: "set-auth-url", authUrl: data.url }); + dispatch({ type: "set-step", step: "oauth-waiting" }); + stopPolling(); + const fid = data.flowId ?? ""; + const reauthQuery = reauthAccountId ? "&reauth=1" : ""; + const statusUrl = fid + ? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${accountId ? `&accountId=${encodeURIComponent(accountId)}` : ""}${reauthQuery}` + : `${apiBase}/api/codex-auth/login-status`; + pollRef.current = setInterval(async () => { + try { + const stRes = await fetch(statusUrl); + const st = await readJsonIfOk<{ status: string; error?: string }>(stRes); + if (!aliveRef.current) return; + if (!st) { + pollErrorStreakRef.current += 1; + if (pollErrorStreakRef.current >= 3) { + dispatch({ type: "set-status-notice", statusNotice: t("codexAuth.oauthStatusRetrying"), statusTone: "warn" }); + } + return; + } + pollErrorStreakRef.current = 0; + if (manualCodeStateRef.current === "waiting") { + dispatch({ type: "set-status-notice", statusNotice: t("codexAuth.oauthCodeSubmitted"), statusTone: "ok" }); + } else { + dispatch({ type: "set-status-notice", statusNotice: "", statusTone: "ok" }); + } + if (st.status === "done") { + stopPolling(); + clearManualCode(); + flowRef.current = null; + dispatch({ type: "set-flow-id", flowId: null }); + if (!aliveRef.current) return; + onAddedRef.current(); + onCloseRef.current(); + } else if (st.status === "error" || st.status === "expired") { + stopPolling(); + clearManualCode(); + flowRef.current = null; + dispatch({ type: "set-flow-id", flowId: null }); + if (aliveRef.current) { + if (!reauthAccountId) dispatch({ type: "set-step", step: "pick" }); + dispatch({ type: "set-error", error: st.error ?? "Login failed" }); + } + } + } catch { + if (!aliveRef.current) return; + pollErrorStreakRef.current += 1; + if (pollErrorStreakRef.current >= 3) { + dispatch({ type: "set-status-notice", statusNotice: t("codexAuth.oauthStatusRetrying"), statusTone: "warn" }); + } + } + }, 2000); + timeoutRef.current = setTimeout(() => { + if (pollRef.current) { + clearManualCode(); + void cancelLogin(); + if (aliveRef.current) { + if (!reauthAccountId) dispatch({ type: "set-step", step: "pick" }); + dispatch({ type: "set-error", error: t("modal.loginTimeout") }); + } + } + }, 300_000); + } + if (data.error && !data.url) dispatch({ type: "set-error", error: data.error }); + } catch (e) { + if (aliveRef.current && !(e instanceof Error && e.name === "AbortError")) { + dispatch({ type: "set-error", error: String(e) }); + } + } + }, [apiBase, cancelLogin, clearManualCode, dispatch, reauthAccountId, stopPolling, t]); + + useEffect(() => { + if (!reauthAccountId) { + startedReauthRef.current = null; + return; + } + if (startedReauthRef.current === reauthAccountId) return; + startedReauthRef.current = reauthAccountId; + void startOAuth(); + }, [reauthAccountId, startOAuth]); + + const copyLoginLink = async () => { + if (!ui.authUrl) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(ui.authUrl); + } else { + const input = document.createElement("textarea"); + input.value = ui.authUrl; + input.style.opacity = "0"; + input.style.position = "fixed"; + document.body.appendChild(input); + input.select(); + document.execCommand("copy"); + document.body.removeChild(input); + } + dispatch({ type: "set-copied", copied: true }); + setTimeout(() => { if (aliveRef.current) dispatch({ type: "set-copied", copied: false }); }, 2500); + } catch { + dispatch({ type: "set-error", error: t("codexAuth.loginLinkCopyFailed") }); + } + }; + + const submitManualCode = useCallback(async () => { + const flowId = flowRef.current; + const input = ui.manualCode.trim(); + if (!flowId || !input || manualCodeBusy || manualCodeWaiting) return; + dispatch({ type: "set-manual-code-state", manualCodeState: "submitting" }); + dispatch({ type: "set-status-notice", statusNotice: "", statusTone: "ok" }); + try { + const resp = await fetch(`${apiBase}/api/codex-auth/login/code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ flowId, input }), + }); + if (!aliveRef.current) return; + if (!resp.ok) { + const data = await resp.json().catch(() => ({})) as { error?: string }; + dispatch({ type: "set-error", error: t("prov.pasteFail", { error: data.error ?? resp.statusText }) }); + dispatch({ type: "set-manual-code-state", manualCodeState: "idle" }); + return; + } + dispatch({ type: "oauth-code-submitted" }); + dispatch({ type: "set-status-notice", statusNotice: t("codexAuth.oauthCodeSubmitted"), statusTone: "ok" }); + pollErrorStreakRef.current = 0; + } catch { + if (aliveRef.current) { + dispatch({ type: "set-error", error: t("modal.networkError") }); + dispatch({ type: "set-manual-code-state", manualCodeState: "idle" }); + } + } + }, [apiBase, dispatch, manualCodeBusy, manualCodeWaiting, t, ui.manualCode]); + + return { + manualCodeBusy, + manualCodeWaiting, + bindCallbacks, + closeModal, + startOAuth, + copyLoginLink, + submitManualCode, + }; +} From 861226043d7396e36fa021356a85a0deffa8daf8 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:49:20 +0200 Subject: [PATCH 2/7] fix(gui): address Codex review on OAuth cleanup and reset a11y Make aliveRef StrictMode-replay-safe, preserve structured login errors on non-2xx, and keep the reset dialog labelled during confirmation. --- .../components/codex-account-reset-modal.tsx | 2 +- .../components/use-add-codex-account-oauth.ts | 51 ++++++++++--------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/gui/src/components/codex-account-reset-modal.tsx b/gui/src/components/codex-account-reset-modal.tsx index 9251335ea..d91ca20e9 100644 --- a/gui/src/components/codex-account-reset-modal.tsx +++ b/gui/src/components/codex-account-reset-modal.tsx @@ -82,7 +82,7 @@ export function CodexAccountResetModal({ <>
-

{t("codexAuth.confirmResetTitle")}

+

{t("codexAuth.confirmResetTitle")}

{t("codexAuth.confirmResetDesc", { count: String(resetPopup.quota?.resetCredits ?? 0) })}

{creditDetails && creditDetails[0] && (

diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts index 955e7e7fb..51b5d01ce 100644 --- a/gui/src/components/use-add-codex-account-oauth.ts +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef } from "react"; import type { Dispatch } from "react"; import type { AddCodexAccountUiAction } from "./add-codex-account-reducer"; import type { TFn } from "../i18n"; -import { readJsonIfOk } from "../fetch-json"; +import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; export function useAddCodexAccountOAuth({ apiBase, @@ -71,23 +71,27 @@ export function useAddCodexAccountOAuth({ }).catch(() => {}); }, [apiBase, clearManualCode, dispatch, stopPolling]); - useEffect(() => () => { - clearManualCode(); - aliveRef.current = false; - loginAbortRef.current?.abort(); - loginAbortRef.current = null; - const flowId = flowRef.current; - flowRef.current = null; - dispatch({ type: "set-flow-id", flowId: null }); - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - if (flowId) { - void fetch(`${apiBase}/api/codex-auth/login/cancel`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ flowId }), - }).catch(() => {}); - } + // Replay-safe under StrictMode: remount must revive aliveRef after the first cleanup. + useEffect(() => { + aliveRef.current = true; + return () => { + clearManualCode(); + aliveRef.current = false; + loginAbortRef.current?.abort(); + loginAbortRef.current = null; + const flowId = flowRef.current; + flowRef.current = null; + dispatch({ type: "set-flow-id", flowId: null }); + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + if (flowId) { + void fetch(`${apiBase}/api/codex-auth/login/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ flowId }), + }).catch(() => {}); + } + }; }, [apiBase, clearManualCode, dispatch]); const bindCallbacks = useCallback((onAdded: () => void, onClose: () => void) => { @@ -123,7 +127,6 @@ export function useAddCodexAccountOAuth({ }); type LoginResponse = { url?: string; flowId?: string; error?: string; status?: string }; let resp = await requestLogin(); - let data = await readJsonIfOk(resp); if (!aliveRef.current) return; if (resp.status === 409) { await fetch(`${apiBase}/api/codex-auth/login/cancel`, { @@ -133,16 +136,14 @@ export function useAddCodexAccountOAuth({ }); if (!aliveRef.current || controller.signal.aborted) return; resp = await requestLogin(); - data = await readJsonIfOk(resp); if (resp.status === 409) { dispatch({ type: "set-error", error: t("codexAuth.oauthAlreadyInProgress") }); return; } } - if (!data) { - dispatch({ type: "set-error", error: t("modal.networkError") }); - return; - } + // Preserve structured `{ error }` bodies on non-2xx instead of collapsing to networkError. + const data = await readJsonOrThrow(resp, t("modal.networkError")); + if (!aliveRef.current) return; if (data.url) { flowRef.current = data.flowId ?? null; dispatch({ type: "set-flow-id", flowId: data.flowId ?? null }); @@ -212,7 +213,7 @@ export function useAddCodexAccountOAuth({ if (data.error && !data.url) dispatch({ type: "set-error", error: data.error }); } catch (e) { if (aliveRef.current && !(e instanceof Error && e.name === "AbortError")) { - dispatch({ type: "set-error", error: String(e) }); + dispatch({ type: "set-error", error: e instanceof Error ? e.message : String(e) }); } } }, [apiBase, cancelLogin, clearManualCode, dispatch, reauthAccountId, stopPolling, t]); From c40e81977400e9fd2bea717957aec7ab4b67f46d Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:02:52 +0200 Subject: [PATCH 3/7] fix(gui): harden Codex OAuth typing and update seam tests after extract Guard undefined login JSON bodies and point source-contract tests at the extracted OAuth/card modules. --- .../components/use-add-codex-account-oauth.ts | 2 +- tests/codex-auth-modal-status.test.ts | 17 +++++++++++------ tests/provider-workspace-auth.test.ts | 17 +++++++++++------ tests/rate-limit-reset-credits.test.ts | 14 +++++++++----- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts index 51b5d01ce..6f774cb5c 100644 --- a/gui/src/components/use-add-codex-account-oauth.ts +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -143,7 +143,7 @@ export function useAddCodexAccountOAuth({ } // Preserve structured `{ error }` bodies on non-2xx instead of collapsing to networkError. const data = await readJsonOrThrow(resp, t("modal.networkError")); - if (!aliveRef.current) return; + if (!aliveRef.current || !data) return; if (data.url) { flowRef.current = data.flowId ?? null; dispatch({ type: "set-flow-id", flowId: data.flowId ?? null }); diff --git a/tests/codex-auth-modal-status.test.ts b/tests/codex-auth-modal-status.test.ts index c8be5a9e0..bbda2616d 100644 --- a/tests/codex-auth-modal-status.test.ts +++ b/tests/codex-auth-modal-status.test.ts @@ -2,12 +2,17 @@ import { describe, expect, test } from "bun:test"; describe("Codex auth modal status feedback", () => { test("keeps a distinct submitted/waiting state for manual code login", async () => { - const source = await Bun.file("gui/src/components/AddCodexAccountModal.tsx").text(); - expect(source).toContain('useState<"idle" | "submitting" | "waiting">("idle")'); - expect(source).toContain('setStatusNotice(t("codexAuth.oauthCodeSubmitted"))'); - expect(source).toContain('setStatusNotice(t("codexAuth.oauthStatusRetrying"))'); - expect(source).toContain('disabled={manualCodeBusy || manualCodeWaiting || !manualCode.trim() || !flowId}'); - expect(source).toContain('aria-live="polite"'); + const [reducer, oauth, waiting] = await Promise.all([ + Bun.file("gui/src/components/add-codex-account-reducer.ts").text(), + Bun.file("gui/src/components/use-add-codex-account-oauth.ts").text(), + Bun.file("gui/src/components/add-codex-account-waiting-step.tsx").text(), + ]); + expect(reducer).toContain('export type ManualCodeState = "idle" | "submitting" | "waiting"'); + expect(reducer).toContain('manualCodeState: "idle"'); + expect(oauth).toContain('statusNotice: t("codexAuth.oauthCodeSubmitted")'); + expect(oauth).toContain('statusNotice: t("codexAuth.oauthStatusRetrying")'); + expect(waiting).toContain("disabled={manualCodeBusy || manualCodeWaiting || !manualCode.trim() || !flowId}"); + expect(waiting).toContain('aria-live="polite"'); }); test("defines the new status copy in every shipped GUI locale", async () => { diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index 346c69434..f77093e7d 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -170,12 +170,15 @@ describe("workspace account integration seam", () => { }); test("wires Codex active reauth health into openai rail status", async () => { - const [page, pool, panel, modal, hook] = await Promise.all([ + const [page, pool, panel, modal, hook, oauthHook, cards, mainCard] = await Promise.all([ providersPageSeam(), Bun.file("gui/src/components/CodexAccountPool.tsx").text(), Bun.file("gui/src/components/provider-workspace/ProviderAuthPanel.tsx").text(), Bun.file("gui/src/components/AddCodexAccountModal.tsx").text(), Bun.file("gui/src/hooks/useCodexAccountPool.ts").text(), + Bun.file("gui/src/components/use-add-codex-account-oauth.ts").text(), + Bun.file("gui/src/components/codex-account-pool-cards.tsx").text(), + Bun.file("gui/src/components/codex-account-pool-main-card.tsx").text(), ]); expect(page).toContain("codexActiveNeedsReauth"); expect(page).toContain("map.openai = true"); @@ -197,11 +200,13 @@ describe("workspace account integration seam", () => { expect(hook).toContain("if (!enabled || pauseCount > 0) return;"); // The initial load must not be re-triggered by pause transitions. expect(hook).toContain("}, [enabled, load]);"); - expect(modal).toContain("reauth: true"); - expect(modal).toContain("startedReauthRef"); - expect(modal).toContain("&reauth=1"); - expect(pool).toContain("codexAuth.reauthenticate"); - expect(pool).toContain("codexAuth.mainTokenExpired"); + // Reauth OAuth payload lives in the extracted OAuth hook (modal only wires props). + expect(oauthHook).toContain("reauth: true"); + expect(oauthHook).toContain("startedReauthRef"); + expect(oauthHook).toContain("&reauth=1"); + expect(modal).toContain("reauthAccountId"); + expect(cards).toContain("codexAuth.reauthenticate"); + expect(mainCard).toContain("codexAuth.mainTokenExpired"); // The panel now shares the controller instead of reporting health upward. expect(panel).toContain("controller={codexController}"); }); diff --git a/tests/rate-limit-reset-credits.test.ts b/tests/rate-limit-reset-credits.test.ts index f385bb6ce..942354714 100644 --- a/tests/rate-limit-reset-credits.test.ts +++ b/tests/rate-limit-reset-credits.test.ts @@ -237,11 +237,15 @@ describe("rate-limit reset credits", () => { }); it("does not exclude team or workspace plans from ticket badges", async () => { - const source = await Bun.file("gui/src/components/CodexAccountPool.tsx").text(); + const [pool, helpers] = await Promise.all([ + Bun.file("gui/src/components/CodexAccountPool.tsx").text(), + Bun.file("gui/src/components/codex-account-pool-helpers.tsx").text(), + ]); + const source = `${pool}\n${helpers}`; expect(source).not.toContain("isWorkspaceAccount"); expect(source).not.toContain("Not available for workspace accounts"); - expect(source).toContain("if (credits === undefined) return null;"); - expect(source).toContain("className={`badge ${hasCredits ? \"badge-amber\" : \"badge-muted\"} badge-clickable`}"); + expect(helpers).toContain("if (credits === undefined) return null;"); + expect(helpers).toContain("className={`badge ${hasCredits ? \"badge-amber\" : \"badge-muted\"} badge-clickable`}"); }); it("keeps clickable ticket badges from overriding visual badge colors", async () => { @@ -254,9 +258,9 @@ describe("rate-limit reset credits", () => { }); it("renders reset tickets beside next-session badges instead of replacing them", async () => { - const source = await Bun.file("gui/src/components/CodexAccountPool.tsx").text(); + const source = await Bun.file("gui/src/components/codex-account-pool-cards.tsx").text(); expect(source).toContain("className=\"card-badges\""); - expect(source).toContain(" openResetPopup(a)} />"); + expect(source).toContain(" onOpenReset(a)} />"); // Since the pool/direct mode split, the next-session badge is conditional on the // account mode (poolPrepared in direct mode) but still renders BESIDE the ticket. expect(source).toContain("{isNext(a.id) && !a.needsReauth && ("); From e7ed8fca153ea1c17e2d5e4b5d7635cbaa130aeb Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:30:30 +0200 Subject: [PATCH 4/7] fix(gui): address CodeRabbit findings on Codex account pool Avoid inventing reset-credit counts, clear toast error tone on redeem, guard OAuth status polling, and localize leftover English fallbacks. --- gui/src/components/CodexAccountPool.tsx | 1 + .../add-codex-account-waiting-step.tsx | 3 +- .../components/codex-account-pool-handlers.ts | 40 ++++++++++++++----- .../codex-account-pool-main-card.tsx | 5 ++- .../components/codex-account-switch-modal.tsx | 2 +- .../components/use-add-codex-account-oauth.ts | 19 ++++++--- gui/src/i18n/de.ts | 4 ++ gui/src/i18n/en.ts | 4 ++ gui/src/i18n/ja.ts | 4 ++ gui/src/i18n/ko.ts | 4 ++ gui/src/i18n/ru.ts | 4 ++ gui/src/i18n/zh.ts | 4 ++ src/codex/auth-api.ts | 11 ++++- 13 files changed, 83 insertions(+), 22 deletions(-) diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 9afa75f2b..5df8ca9b6 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -194,6 +194,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setResetConfirm(false); } if (result.toast) { + setToastError(!result.ok); setToast(result.toast); setTimeout(() => setToast(""), 5000); } diff --git a/gui/src/components/add-codex-account-waiting-step.tsx b/gui/src/components/add-codex-account-waiting-step.tsx index 72eff5332..fd7fe4c6c 100644 --- a/gui/src/components/add-codex-account-waiting-step.tsx +++ b/gui/src/components/add-codex-account-waiting-step.tsx @@ -1,5 +1,6 @@ import { IconLink } from "../icons"; import { useT } from "../i18n"; +import type { StatusTone } from "./add-codex-account-reducer"; export function AddCodexAccountWaitingStep({ reauthAccountId, @@ -24,7 +25,7 @@ export function AddCodexAccountWaitingStep({ manualCodeBusy: boolean; manualCodeWaiting: boolean; statusNotice: string; - statusTone: "ok" | "warn"; + statusTone: StatusTone; flowId: string | null; error: string; onCopyLoginLink: () => void; diff --git a/gui/src/components/codex-account-pool-handlers.ts b/gui/src/components/codex-account-pool-handlers.ts index b6e02ad5d..644ca7932 100644 --- a/gui/src/components/codex-account-pool-handlers.ts +++ b/gui/src/components/codex-account-pool-handlers.ts @@ -2,6 +2,14 @@ import type { TFn } from "../i18n"; import { readJsonIfOk } from "../fetch-json"; import type { CodexAccountEntry } from "./codex-account-pool-types"; +function remainingCreditsToast( + t: TFn, + remaining: number | undefined, +): string { + if (remaining === undefined) return t("codexAuth.resetSuccessGeneric"); + return t("codexAuth.resetSuccess", { remaining: String(remaining) }); +} + export async function redeemResetCredit( apiBase: string, accountId: string, @@ -15,24 +23,34 @@ export async function redeemResetCredit( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ accountId }), }); - if (!resp.ok) { alert(t("codexAuth.resetError")); return { ok: false }; } - const result = await readJsonIfOk<{ code: string }>(resp); - if (!result) { alert(t("codexAuth.resetError")); return { ok: false }; } + const result = await readJsonIfOk<{ code: string; remaining?: number }>(resp); + if (!result) return { ok: false, toast: t("codexAuth.resetError") }; if (result.code === "reset" || result.code === "already_redeemed") { - const prevCredits = resetPopup?.quota?.resetCredits ?? 1; await load(true); + const serverRemaining = + typeof result.remaining === "number" && Number.isFinite(result.remaining) + ? Math.max(0, result.remaining) + : undefined; + const knownCredits = resetPopup?.quota?.resetCredits; + // Prefer server remaining. Never invent a decrement for already_redeemed / unknown counts. + const remaining = + serverRemaining + ?? (result.code === "already_redeemed" + ? (typeof knownCredits === "number" ? knownCredits : undefined) + : (typeof knownCredits === "number" ? Math.max(0, knownCredits - 1) : undefined)); return { ok: true, close: true, - toast: t("codexAuth.resetSuccess", { remaining: String(Math.max(0, prevCredits - 1)) }), + toast: result.code === "already_redeemed" && remaining === undefined + ? t("codexAuth.resetAlreadyRedeemed") + : remainingCreditsToast(t, remaining), }; } - if (result.code === "nothing_to_reset") alert(t("codexAuth.resetNothingToReset")); - else if (result.code === "no_credit") alert(t("codexAuth.resetNoCredit")); - else alert(t("codexAuth.resetError")); - return { ok: false }; + const key = result.code === "nothing_to_reset" ? "codexAuth.resetNothingToReset" + : result.code === "no_credit" ? "codexAuth.resetNoCredit" + : "codexAuth.resetError"; + return { ok: false, close: true, toast: t(key) }; } catch { - alert(t("codexAuth.resetError")); - return { ok: false }; + return { ok: false, toast: t("codexAuth.resetError") }; } } diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index 25afc132f..94911e622 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -25,9 +25,10 @@ export function CodexAccountPoolMainCard({ onSwitch: (entry: CodexAccountEntry) => void; onOpenReset: (account: CodexAccountEntry) => void; }) { + const mainFallbackLabel = t("codexAuth.codexApp"); const mainSwitchEntry: CodexAccountEntry = { id: "__main__", - email: main?.email ?? "Codex App", + email: main?.email || mainFallbackLabel, plan: main?.plan, isMain: true, hasCredential: true, @@ -55,7 +56,7 @@ export function CodexAccountPoolMainCard({ )} {t("codexAuth.appLogin")}

-
{main?.email ?? "Codex App login"}{main?.plan ? ` · ${main.plan}` : ""}
+
{main?.email || t("codexAuth.appLogin")}{main?.plan ? ` · ${main.plan}` : ""}
{main?.needsReauth ?
{t("codexAuth.mainTokenExpired")}
: main?.quota && } diff --git a/gui/src/components/codex-account-switch-modal.tsx b/gui/src/components/codex-account-switch-modal.tsx index cea7ba5ef..0fd209e04 100644 --- a/gui/src/components/codex-account-switch-modal.tsx +++ b/gui/src/components/codex-account-switch-modal.tsx @@ -50,7 +50,7 @@ export function CodexAccountSwitchModal({ : confirm.id === "__main__" ? t("codexAuth.switchBackDesc") : t("codexAuth.switchDesc")}

- {confirm.id === "__main__" ? mainEmail : confirm.email} + {confirm.id === "__main__" ? (mainEmail || t("codexAuth.codexApp")) : confirm.email} {confirm.plan && {confirm.plan}}
{confirm.id !== "__main__" && ( diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts index 6f774cb5c..461c1ae21 100644 --- a/gui/src/components/use-add-codex-account-oauth.ts +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -1,6 +1,10 @@ import { useCallback, useEffect, useRef } from "react"; import type { Dispatch } from "react"; -import type { AddCodexAccountUiAction } from "./add-codex-account-reducer"; +import type { + AddCodexAccountStep, + AddCodexAccountUiAction, + ManualCodeState, +} from "./add-codex-account-reducer"; import type { TFn } from "../i18n"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; @@ -14,8 +18,8 @@ export function useAddCodexAccountOAuth({ apiBase: string; reauthAccountId?: string; ui: { - step: string; - manualCodeState: string; + step: AddCodexAccountStep; + manualCodeState: ManualCodeState; manualCode: string; authUrl: string; flowId: string | null; @@ -25,6 +29,7 @@ export function useAddCodexAccountOAuth({ }) { const aliveRef = useRef(true); const pollErrorStreakRef = useRef(0); + const pollInFlightRef = useRef(false); const pollRef = useRef | null>(null); const timeoutRef = useRef | null>(null); const flowRef = useRef(null); @@ -156,8 +161,10 @@ export function useAddCodexAccountOAuth({ ? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${accountId ? `&accountId=${encodeURIComponent(accountId)}` : ""}${reauthQuery}` : `${apiBase}/api/codex-auth/login-status`; pollRef.current = setInterval(async () => { + if (pollInFlightRef.current) return; + pollInFlightRef.current = true; try { - const stRes = await fetch(statusUrl); + const stRes = await fetch(statusUrl, { signal: AbortSignal.timeout(10_000) }); const st = await readJsonIfOk<{ status: string; error?: string }>(stRes); if (!aliveRef.current) return; if (!st) { @@ -188,7 +195,7 @@ export function useAddCodexAccountOAuth({ dispatch({ type: "set-flow-id", flowId: null }); if (aliveRef.current) { if (!reauthAccountId) dispatch({ type: "set-step", step: "pick" }); - dispatch({ type: "set-error", error: st.error ?? "Login failed" }); + dispatch({ type: "set-error", error: st.error ?? t("codexAuth.loginFailed") }); } } } catch { @@ -197,6 +204,8 @@ export function useAddCodexAccountOAuth({ if (pollErrorStreakRef.current >= 3) { dispatch({ type: "set-status-notice", statusNotice: t("codexAuth.oauthStatusRetrying"), statusTone: "warn" }); } + } finally { + pollInFlightRef.current = false; } }, 2000); timeoutRef.current = setTimeout(() => { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 214e0653d..e3de7e812 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -601,6 +601,7 @@ export const de: Record = { "nav.openMenu": "Menü öffnen", "nav.closeMenu": "Menü schließen", "codexAuth.mainAccount": "Hauptkonto", + "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "App-Login", "codexAuth.accountPool": "Kontopool", "codexAuth.accountModeTitle": "OpenAI-Kontomodus", @@ -680,6 +681,7 @@ export const de: Record = { "codexAuth.loginLinkCopied": "Login-Link kopiert", "codexAuth.loginLinkCopyFailed": "Login-Link konnte nicht kopiert werden.", "codexAuth.oauthCancelled": "Login wurde abgebrochen.", + "codexAuth.loginFailed": "Login fehlgeschlagen", "codexAuth.needsReauth": "Erneut anmelden", "codexAuth.reauthenticate": "Re-authenticate", "codexAuth.tokenExpired": "Token abgelaufen — dieses Konto erneut authentifizieren", @@ -698,6 +700,8 @@ export const de: Record = { "codexAuth.useCredit": "Gutschrift nutzen", "codexAuth.redeeming": "Wird zurückgesetzt…", "codexAuth.resetSuccess": "Ratenbegrenzungen zurückgesetzt! {remaining} Gutschrift(en) übrig.", + "codexAuth.resetSuccessGeneric": "Ratenbegrenzungen zurückgesetzt!", + "codexAuth.resetAlreadyRedeemed": "Diese Gutschrift wurde bereits eingelöst. Gutschriften unverändert.", "codexAuth.resetNothingToReset": "Kein Ratenbegrenzungs-Fenster muss gerade zurückgesetzt werden.", "codexAuth.resetNoCredit": "Keine Reset-Gutschriften verfügbar.", "codexAuth.resetError": "Reset-Gutschrift konnte nicht eingelöst werden. Bitte erneut versuchen.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 246a3f756..660e58a98 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -899,6 +899,7 @@ export const en = { "nav.openMenu": "Open menu", "nav.closeMenu": "Close menu", "codexAuth.mainAccount": "Main Account", + "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "App login", "codexAuth.accountPool": "Account Pool", "codexAuth.accountModeTitle": "OpenAI account mode", @@ -980,6 +981,7 @@ export const en = { "codexAuth.loginLinkCopied": "Login link copied", "codexAuth.loginLinkCopyFailed": "Could not copy login link.", "codexAuth.oauthCancelled": "Login was cancelled.", + "codexAuth.loginFailed": "Login failed", "codexAuth.needsReauth": "Re-login", "codexAuth.reauthenticate": "Re-authenticate", "codexAuth.tokenExpired": "Token expired — re-authenticate this account", @@ -999,6 +1001,8 @@ export const en = { "codexAuth.useCredit": "Use Credit", "codexAuth.redeeming": "Resetting...", "codexAuth.resetSuccess": "Rate limits reset! {remaining} credit(s) remaining.", + "codexAuth.resetSuccessGeneric": "Rate limits reset!", + "codexAuth.resetAlreadyRedeemed": "This credit was already redeemed. Credits unchanged.", "codexAuth.resetNothingToReset": "No rate-limit window needs resetting right now.", "codexAuth.resetNoCredit": "No reset credits available.", "codexAuth.resetError": "Failed to redeem reset credit. Please try again.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 8dd75b331..3f55e6b57 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -853,6 +853,7 @@ export const ja: Record = { "nav.openMenu": "メニューを開く", "nav.closeMenu": "メニューを閉じる", "codexAuth.mainAccount": "メインアカウント", + "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "アプリログイン", "codexAuth.accountPool": "アカウントプール", "codexAuth.accountModeTitle": "OpenAI アカウントモード", @@ -934,6 +935,7 @@ export const ja: Record = { "codexAuth.loginLinkCopied": "ログインリンクをコピーしました", "codexAuth.loginLinkCopyFailed": "ログインリンクをコピーできませんでした。", "codexAuth.oauthCancelled": "ログインはキャンセルされました。", + "codexAuth.loginFailed": "ログインに失敗しました", "codexAuth.needsReauth": "再ログイン", "codexAuth.reauthenticate": "再認証", "codexAuth.tokenExpired": "トークンが期限切れ — このアカウントを再認証してください", @@ -953,6 +955,8 @@ export const ja: Record = { "codexAuth.useCredit": "クレジットを使用", "codexAuth.redeeming": "リセット中...", "codexAuth.resetSuccess": "レート制限をリセットしました! 残り {remaining} クレジット。", + "codexAuth.resetSuccessGeneric": "レート制限をリセットしました!", + "codexAuth.resetAlreadyRedeemed": "このクレジットは既に引き換え済みです。クレジットは変わりません。", "codexAuth.resetNothingToReset": "今リセットが必要なレート制限枠はありません。", "codexAuth.resetNoCredit": "利用可能なリセットクレジットはありません。", "codexAuth.resetError": "リセットクレジットの引き換えに失敗しました。もう一度お試しください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e3645402e..5fe37b020 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -618,6 +618,7 @@ export const ko: Record = { "nav.openMenu": "메뉴 열기", "nav.closeMenu": "메뉴 닫기", "codexAuth.mainAccount": "메인 계정", + "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "앱 로그인", "codexAuth.accountPool": "계정 풀", "codexAuth.accountModeTitle": "OpenAI 계정 모드", @@ -697,6 +698,7 @@ export const ko: Record = { "codexAuth.loginLinkCopied": "로그인 링크를 복사했습니다", "codexAuth.loginLinkCopyFailed": "로그인 링크를 복사하지 못했습니다.", "codexAuth.oauthCancelled": "로그인이 취소되었습니다.", + "codexAuth.loginFailed": "로그인에 실패했습니다", "codexAuth.needsReauth": "재로그인", "codexAuth.reauthenticate": "Re-authenticate", "codexAuth.tokenExpired": "토큰 만료 — 이 계정을 다시 인증하세요", @@ -716,6 +718,8 @@ export const ko: Record = { "codexAuth.useCredit": "크레딧 사용", "codexAuth.redeeming": "초기화 중...", "codexAuth.resetSuccess": "사용량 제한이 초기화되었습니다! 남은 크레딧: {remaining}개.", + "codexAuth.resetSuccessGeneric": "사용량 제한이 초기화되었습니다!", + "codexAuth.resetAlreadyRedeemed": "이 크레딧은 이미 사용되었습니다. 크레딧은 변경되지 않았습니다.", "codexAuth.resetNothingToReset": "현재 초기화할 사용량 윈도우가 없습니다.", "codexAuth.resetNoCredit": "사용 가능한 리셋 크레딧이 없습니다.", "codexAuth.resetError": "리셋 크레딧 사용에 실패했습니다. 다시 시도해 주세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 19770fac3..1900f055e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -898,6 +898,7 @@ export const ru: Record = { "nav.openMenu": "Открыть меню", "nav.closeMenu": "Закрыть меню", "codexAuth.mainAccount": "Основной аккаунт", + "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "Вход через приложение", "codexAuth.accountPool": "Пул аккаунтов", "codexAuth.accountModeTitle": "Режим аккаунта OpenAI", @@ -979,6 +980,7 @@ export const ru: Record = { "codexAuth.loginLinkCopied": "Ссылка для входа скопирована", "codexAuth.loginLinkCopyFailed": "Не удалось скопировать ссылку для входа.", "codexAuth.oauthCancelled": "Вход отменён.", + "codexAuth.loginFailed": "Не удалось войти", "codexAuth.needsReauth": "Повторный вход", "codexAuth.reauthenticate": "Переавторизоваться", "codexAuth.tokenExpired": "Токен истёк — переавторизуйте этот аккаунт", @@ -998,6 +1000,8 @@ export const ru: Record = { "codexAuth.useCredit": "Использовать кредит", "codexAuth.redeeming": "Сброс...", "codexAuth.resetSuccess": "Лимиты запросов сброшены! Осталось кредитов: {remaining}.", + "codexAuth.resetSuccessGeneric": "Лимиты запросов сброшены!", + "codexAuth.resetAlreadyRedeemed": "Этот кредит уже был использован. Количество кредитов не изменилось.", "codexAuth.resetNothingToReset": "Сейчас ни одно окно лимитов не требует сброса.", "codexAuth.resetNoCredit": "Нет доступных кредитов сброса.", "codexAuth.resetError": "Не удалось использовать кредит сброса. Попробуйте ещё раз.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7f29a64ee..1c211b777 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -618,6 +618,7 @@ export const zh: Record = { "nav.openMenu": "打开菜单", "nav.closeMenu": "关闭菜单", "codexAuth.mainAccount": "主账号", + "codexAuth.codexApp": "Codex App", "codexAuth.appLogin": "应用登录", "codexAuth.accountPool": "账号池", "codexAuth.accountModeTitle": "OpenAI 账户模式", @@ -697,6 +698,7 @@ export const zh: Record = { "codexAuth.loginLinkCopied": "登录链接已复制", "codexAuth.loginLinkCopyFailed": "无法复制登录链接。", "codexAuth.oauthCancelled": "登录已取消。", + "codexAuth.loginFailed": "登录失败", "codexAuth.needsReauth": "重新登录", "codexAuth.reauthenticate": "Re-authenticate", "codexAuth.tokenExpired": "令牌已过期 — 请重新认证此账号", @@ -716,6 +718,8 @@ export const zh: Record = { "codexAuth.useCredit": "使用额度", "codexAuth.redeeming": "重置中...", "codexAuth.resetSuccess": "使用限制已重置!剩余额度:{remaining} 个。", + "codexAuth.resetSuccessGeneric": "使用限制已重置!", + "codexAuth.resetAlreadyRedeemed": "该额度已兑换过,额度未变。", "codexAuth.resetNothingToReset": "当前没有需要重置的使用窗口。", "codexAuth.resetNoCredit": "没有可用的重置额度。", "codexAuth.resetError": "重置额度使用失败,请重试。", diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 6aaf9f68a..6861f9684 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -143,9 +143,16 @@ function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; e }; } -function safeResetCreditConsumeDto(input: unknown): { code: string } { +function safeResetCreditConsumeDto(input: unknown): { code: string; remaining?: number } { const obj = typeof input === "object" && input !== null ? input as Record : {}; - return { code: typeof obj.code === "string" ? obj.code : "unknown" }; + const code = typeof obj.code === "string" ? obj.code : "unknown"; + const rawRemaining = obj.remaining + ?? obj.available_count + ?? (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count; + return { + code, + ...(typeof rawRemaining === "number" && Number.isFinite(rawRemaining) ? { remaining: rawRemaining } : {}), + }; } export function isUnverifiedCodexImportEnabled(): boolean { From fa8c2c7e163829b1d587ff2d7fd9f2d702fcff4e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:46:15 +0200 Subject: [PATCH 5/7] fix(gui): make Codex reauth StrictMode-safe and abort stalled polls Clear startedReauthRef on cleanup so StrictMode remount can restart OAuth, abort in-flight login-status polls on cancel, and add focused regression tests for reauth, single-flight polling, already_redeemed, and toast tone. --- .../components/use-add-codex-account-oauth.ts | 31 +++- gui/tests/add-codex-account-oauth.test.tsx | 160 ++++++++++++++++++ gui/tests/codex-account-pool-handlers.test.ts | 75 ++++++++ .../codex-account-pool-toast-tone.test.tsx | 154 +++++++++++++++++ 4 files changed, 411 insertions(+), 9 deletions(-) create mode 100644 gui/tests/add-codex-account-oauth.test.tsx create mode 100644 gui/tests/codex-account-pool-handlers.test.ts create mode 100644 gui/tests/codex-account-pool-toast-tone.test.tsx diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts index 461c1ae21..7a33a0e90 100644 --- a/gui/src/components/use-add-codex-account-oauth.ts +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -32,6 +32,7 @@ export function useAddCodexAccountOAuth({ const pollInFlightRef = useRef(false); const pollRef = useRef | null>(null); const timeoutRef = useRef | null>(null); + const pollAbortRef = useRef(null); const flowRef = useRef(null); const manualCodeStateRef = useRef(ui.manualCodeState); const loginAbortRef = useRef(null); @@ -52,6 +53,9 @@ export function useAddCodexAccountOAuth({ const stopPolling = useCallback(() => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + pollAbortRef.current?.abort(); + pollAbortRef.current = null; + pollInFlightRef.current = false; }, []); const clearManualCode = useCallback(() => { @@ -76,19 +80,20 @@ export function useAddCodexAccountOAuth({ }).catch(() => {}); }, [apiBase, clearManualCode, dispatch, stopPolling]); - // Replay-safe under StrictMode: remount must revive aliveRef after the first cleanup. + // Replay-safe under StrictMode: remount must revive aliveRef and clear the + // reauth latch so the remounted effect can start OAuth again. useEffect(() => { aliveRef.current = true; return () => { clearManualCode(); aliveRef.current = false; + startedReauthRef.current = null; loginAbortRef.current?.abort(); loginAbortRef.current = null; const flowId = flowRef.current; flowRef.current = null; dispatch({ type: "set-flow-id", flowId: null }); - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } - if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } + stopPolling(); if (flowId) { void fetch(`${apiBase}/api/codex-auth/login/cancel`, { method: "POST", @@ -97,7 +102,7 @@ export function useAddCodexAccountOAuth({ }).catch(() => {}); } }; - }, [apiBase, clearManualCode, dispatch]); + }, [apiBase, clearManualCode, dispatch, stopPolling]); const bindCallbacks = useCallback((onAdded: () => void, onClose: () => void) => { onAddedRef.current = onAdded; @@ -160,13 +165,20 @@ export function useAddCodexAccountOAuth({ const statusUrl = fid ? `${apiBase}/api/codex-auth/login-status?flowId=${encodeURIComponent(fid)}${accountId ? `&accountId=${encodeURIComponent(accountId)}` : ""}${reauthQuery}` : `${apiBase}/api/codex-auth/login-status`; + const pollSession = new AbortController(); + pollAbortRef.current = pollSession; pollRef.current = setInterval(async () => { - if (pollInFlightRef.current) return; + if (pollInFlightRef.current || pollSession.signal.aborted) return; pollInFlightRef.current = true; + // Bound each tick and abort it when stopPolling/cleanup cancels the session. + const tickSignal = AbortSignal.any([ + pollSession.signal, + AbortSignal.timeout(10_000), + ]); try { - const stRes = await fetch(statusUrl, { signal: AbortSignal.timeout(10_000) }); + const stRes = await fetch(statusUrl, { signal: tickSignal }); const st = await readJsonIfOk<{ status: string; error?: string }>(stRes); - if (!aliveRef.current) return; + if (!aliveRef.current || pollSession.signal.aborted) return; if (!st) { pollErrorStreakRef.current += 1; if (pollErrorStreakRef.current >= 3) { @@ -198,8 +210,9 @@ export function useAddCodexAccountOAuth({ dispatch({ type: "set-error", error: st.error ?? t("codexAuth.loginFailed") }); } } - } catch { - if (!aliveRef.current) return; + } catch (e) { + if (!aliveRef.current || pollSession.signal.aborted) return; + if (e instanceof Error && e.name === "AbortError") return; pollErrorStreakRef.current += 1; if (pollErrorStreakRef.current >= 3) { dispatch({ type: "set-status-notice", statusNotice: t("codexAuth.oauthStatusRetrying"), statusTone: "warn" }); diff --git a/gui/tests/add-codex-account-oauth.test.tsx b/gui/tests/add-codex-account-oauth.test.tsx new file mode 100644 index 000000000..5f2a9408c --- /dev/null +++ b/gui/tests/add-codex-account-oauth.test.tsx @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, StrictMode, useReducer } from "react"; +import type { Root } from "react-dom/client"; +import { + addCodexAccountUiReducer, + initialAddCodexAccountUiState, +} from "../src/components/add-codex-account-reducer"; +import { useAddCodexAccountOAuth } from "../src/components/use-add-codex-account-oauth"; +import { LanguageProvider } from "../src/i18n/provider"; + +/** + * StrictMode reauth latch + login-status single-flight / abort contracts for + * useAddCodexAccountOAuth (PR #475 blockers). + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; + +type FetchCall = { + path: string; + method: string; + signal?: AbortSignal | null; +}; + +let calls: FetchCall[] = []; +let statusHolders: Array<{ resolve: (value: Response) => void }> = []; +let loginCount = 0; + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + originalFetch = globalThis.fetch; + calls = []; + statusHolders = []; + loginCount = 0; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + const method = init?.method ?? "GET"; + calls.push({ path: `${url.pathname}${url.search}`, method, signal: init?.signal ?? null }); + + if (url.pathname === "/api/codex-auth/login" && method === "POST") { + loginCount += 1; + return Response.json({ + url: "https://auth.example/login", + flowId: `flow-${loginCount}`, + }); + } + if (url.pathname === "/api/codex-auth/login/cancel") { + return Response.json({ ok: true }); + } + if (url.pathname === "/api/codex-auth/login-status") { + return await new Promise((resolve) => { + statusHolders.push({ resolve }); + }); + } + return Response.json({}); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + for (const holder of statusHolders.splice(0)) { + holder.resolve(Response.json({ status: "pending" })); + } + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +function Probe({ reauthAccountId }: { reauthAccountId: string }) { + const [ui, dispatch] = useReducer( + addCodexAccountUiReducer, + reauthAccountId, + initialAddCodexAccountUiState, + ); + useAddCodexAccountOAuth({ + apiBase: "", + reauthAccountId, + ui, + dispatch, + t: ((key: string) => key) as never, + }); + return
; +} + +async function mountProbe(strict: boolean) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + const tree = ; + root.render(strict ? {tree} : tree); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +test("StrictMode remount clears the reauth latch and starts OAuth again", async () => { + await mountProbe(true); + await act(async () => { await new Promise((r) => setTimeout(r, 60)); }); + + const loginPosts = calls.filter((c) => c.path === "/api/codex-auth/login" && c.method === "POST"); + // Dev StrictMode: setup → cleanup (clears startedReauthRef) → setup again. + expect(loginPosts.length).toBeGreaterThanOrEqual(2); + expect(loginCount).toBeGreaterThanOrEqual(2); +}); + +test("slow login-status polls stay single-flight and abort on unmount", async () => { + await mountProbe(false); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); + + // Wait past two interval ticks while the first status response is still held. + await act(async () => { await new Promise((r) => setTimeout(r, 4500)); }); + + const statusCalls = calls.filter((c) => c.path.includes("/api/codex-auth/login-status")); + expect(statusCalls.length).toBe(1); + expect(statusHolders.length).toBe(1); + + const inFlightSignal = statusCalls[0]?.signal; + expect(inFlightSignal).toBeTruthy(); + + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + + expect(inFlightSignal!.aborted).toBe(true); + + // A late tick must not open a second in-flight poll after cleanup. + await act(async () => { await new Promise((r) => setTimeout(r, 2500)); }); + const statusAfter = calls.filter((c) => c.path.includes("/api/codex-auth/login-status")); + expect(statusAfter.length).toBe(1); +}, { timeout: 20_000 }); diff --git a/gui/tests/codex-account-pool-handlers.test.ts b/gui/tests/codex-account-pool-handlers.test.ts new file mode 100644 index 000000000..e1d2b3db0 --- /dev/null +++ b/gui/tests/codex-account-pool-handlers.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { redeemResetCredit } from "../src/components/codex-account-pool-handlers"; +import type { CodexAccountEntry } from "../src/components/codex-account-pool-types"; +import type { TFn } from "../src/i18n"; + +const t: TFn = ((key: string, vars?: Record) => { + if (!vars) return key; + return `${key}:${Object.entries(vars).map(([k, v]) => `${k}=${v}`).join(",")}`; +}) as TFn; + +let originalFetch: typeof globalThis.fetch; +let consumeBody: { code: string; remaining?: number } | null = null; +let loadCalls = 0; + +const popup: CodexAccountEntry = { + id: "acct-1", + email: "a@example.test", + isMain: false, + hasCredential: true, + quota: { resetCredits: 3, updatedAt: 1 }, +}; + +beforeEach(() => { + originalFetch = globalThis.fetch; + consumeBody = null; + loadCalls = 0; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => Response.json(consumeBody ?? { code: "error" }), + }); +}); + +afterEach(() => { + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); +}); + +test("already_redeemed never invents a decrement when the server omits remaining", async () => { + consumeBody = { code: "already_redeemed" }; + const result = await redeemResetCredit("", "acct-1", t, popup, async () => { + loadCalls += 1; + return true; + }); + + expect(loadCalls).toBe(1); + expect(result.ok).toBe(true); + expect(result.close).toBe(true); + // Keep the known count; do not toast "2 remaining" from a local guess. + expect(result.toast).toBe("codexAuth.resetSuccess:remaining=3"); + expect(result.toast).not.toContain("remaining=2"); +}); + +test("already_redeemed prefers the authoritative server remaining count", async () => { + consumeBody = { code: "already_redeemed", remaining: 3 }; + const result = await redeemResetCredit("", "acct-1", t, popup, async () => true); + + expect(result.ok).toBe(true); + expect(result.toast).toBe("codexAuth.resetSuccess:remaining=3"); +}); + +test("already_redeemed without known credits uses the dedicated toast, not a guessed count", async () => { + consumeBody = { code: "already_redeemed" }; + const noCredits: CodexAccountEntry = { ...popup, quota: { updatedAt: 1 } }; + const result = await redeemResetCredit("", "acct-1", t, noCredits, async () => true); + + expect(result.ok).toBe(true); + expect(result.toast).toBe("codexAuth.resetAlreadyRedeemed"); +}); + +test("failure paths return ok:false so callers can set toastError from result.ok", async () => { + consumeBody = { code: "no_credit" }; + const result = await redeemResetCredit("", "acct-1", t, popup, async () => true); + + expect(result.ok).toBe(false); + expect(result.toast).toBe("codexAuth.resetNoCredit"); +}); diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx new file mode 100644 index 000000000..af2db2f8f --- /dev/null +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import CodexAccountPool from "../src/components/CodexAccountPool"; +import type { CodexAccountEntry, CodexAccountPoolController } from "../src/hooks/useCodexAccountPool"; +import { LanguageProvider } from "../src/i18n/provider"; + +/** + * Stale toastError must not paint a successful redeem as notice-err (PR #475). + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; +let originalConfirm: typeof window.confirm; + +const account: CodexAccountEntry = { + id: "pool-1", + email: "pool@example.test", + isMain: false, + hasCredential: true, + quota: { resetCredits: 2, updatedAt: 1 }, +}; + +function makeController(overrides: Partial = {}): CodexAccountPoolController { + return { + accounts: [ + { id: "main", email: "main@example.test", isMain: true, hasCredential: true, quota: null }, + account, + ], + activeId: null, + loadState: "ready", + switchingId: null, + activeNeedsReauth: false, + load: async () => true, + switchAccount: async () => ({ ok: true, activeId: null }), + saveAlias: async () => ({ ok: true }), + removeAccount: async () => ({ ok: false, reason: "request" }), + syncAfterAccountAdded: async () => ({ ok: true }), + pauseRefresh: () => ({ __brand: "codex-pool-pause" }) as never, + resumeRefresh: () => {}, + subscribeLoadObserver: () => () => {}, + readLastThreshold: () => undefined, + ...overrides, + }; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + originalFetch = globalThis.fetch; + originalConfirm = window.confirm; + window.confirm = () => true; + + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input), "http://localhost"); + if (url.pathname === "/api/codex-auth/reset-credits" && !url.pathname.endsWith("/consume")) { + return Response.json({ credits: [] }); + } + if (url.pathname === "/api/codex-auth/reset-credits/consume" && (init?.method ?? "GET") === "POST") { + return Response.json({ code: "already_redeemed", remaining: 2 }); + } + if (url.pathname.startsWith("/api/codex-auth/")) { + return Response.json({ accounts: [], activeCodexAccountId: null, autoSwitchThreshold: 80 }); + } + return Response.json({}); + }, + }); + + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + window.confirm = originalConfirm; + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + await win.happyDOM?.close?.(); +}); + +async function mountPool(controller: CodexAccountPoolController) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); +} + +test("successful redeem clears a stale error toast tone", async () => { + await mountPool(makeController()); + + // Seed toastError=true via a failed remove. + const removeBtn = [...host.querySelectorAll("button")].find((btn) => + (btn.getAttribute("aria-label") ?? "").includes("pool@example.test") + && (btn.getAttribute("aria-label") ?? "").toLowerCase().includes("remove"), + ); + expect(removeBtn).toBeTruthy(); + await act(async () => { removeBtn!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + + const errNotice = host.querySelector(".notice-err"); + expect(errNotice).toBeTruthy(); + + const resetBtn = host.querySelector('button[aria-label="2 reset credit(s)"]') as HTMLButtonElement | null; + expect(resetBtn).toBeTruthy(); + await act(async () => { resetBtn!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); + + const useCredit = [...host.querySelectorAll("button")].find((btn) => + (btn.textContent ?? "").includes("Use 1 Credit"), + ); + expect(useCredit).toBeTruthy(); + await act(async () => { useCredit!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + + const confirmReset = [...host.querySelectorAll("button")].find((btn) => { + const text = (btn.textContent ?? "").trim(); + return text === "Use Credit" || text.startsWith("Resetting"); + }); + expect(confirmReset).toBeTruthy(); + await act(async () => { confirmReset!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); + await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); + + expect(host.querySelector(".notice-err")).toBeNull(); + expect(host.querySelector(".notice-ok")).toBeTruthy(); +}); From 23593db9a9bfc7d00353c88847c211ec990691ca Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:43:07 +0200 Subject: [PATCH 6/7] fix(codex): return authoritative remaining after reset-credit consume Refresh WHAM quota after reset/already_redeemed and return remaining from stored quota. Drop GUI knownCredits-1 and modal-snapshot arithmetic so toasts never invent a decrement. --- gui/src/components/CodexAccountPool.tsx | 2 +- .../components/codex-account-pool-handlers.ts | 17 +-- gui/tests/codex-account-pool-handlers.test.ts | 48 ++++---- src/codex/auth-api.ts | 22 ++-- tests/codex-auth-api.test.ts | 105 ++++++++++++++++++ 5 files changed, 149 insertions(+), 45 deletions(-) diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 5df8ca9b6..5401dd3f2 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -188,7 +188,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const handleRedeem = async (accountId: string) => { setRedeeming(true); try { - const result = await redeemResetCredit(apiBase, accountId, t, resetPopup, load); + const result = await redeemResetCredit(apiBase, accountId, t, load); if (result.close) { setResetPopup(null); setResetConfirm(false); diff --git a/gui/src/components/codex-account-pool-handlers.ts b/gui/src/components/codex-account-pool-handlers.ts index 644ca7932..094bf819f 100644 --- a/gui/src/components/codex-account-pool-handlers.ts +++ b/gui/src/components/codex-account-pool-handlers.ts @@ -1,6 +1,5 @@ import type { TFn } from "../i18n"; import { readJsonIfOk } from "../fetch-json"; -import type { CodexAccountEntry } from "./codex-account-pool-types"; function remainingCreditsToast( t: TFn, @@ -14,7 +13,6 @@ export async function redeemResetCredit( apiBase: string, accountId: string, t: TFn, - resetPopup: CodexAccountEntry | null, load: (refresh?: boolean) => Promise, ): Promise<{ ok: boolean; toast?: string; close?: boolean }> { try { @@ -27,23 +25,16 @@ export async function redeemResetCredit( if (!result) return { ok: false, toast: t("codexAuth.resetError") }; if (result.code === "reset" || result.code === "already_redeemed") { await load(true); - const serverRemaining = + // Authoritative remaining comes from the management endpoint (refreshed quota). + // Never invent a decrement from a stale modal snapshot. + const remaining = typeof result.remaining === "number" && Number.isFinite(result.remaining) ? Math.max(0, result.remaining) : undefined; - const knownCredits = resetPopup?.quota?.resetCredits; - // Prefer server remaining. Never invent a decrement for already_redeemed / unknown counts. - const remaining = - serverRemaining - ?? (result.code === "already_redeemed" - ? (typeof knownCredits === "number" ? knownCredits : undefined) - : (typeof knownCredits === "number" ? Math.max(0, knownCredits - 1) : undefined)); return { ok: true, close: true, - toast: result.code === "already_redeemed" && remaining === undefined - ? t("codexAuth.resetAlreadyRedeemed") - : remainingCreditsToast(t, remaining), + toast: remainingCreditsToast(t, remaining), }; } const key = result.code === "nothing_to_reset" ? "codexAuth.resetNothingToReset" diff --git a/gui/tests/codex-account-pool-handlers.test.ts b/gui/tests/codex-account-pool-handlers.test.ts index e1d2b3db0..7312f6f16 100644 --- a/gui/tests/codex-account-pool-handlers.test.ts +++ b/gui/tests/codex-account-pool-handlers.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { redeemResetCredit } from "../src/components/codex-account-pool-handlers"; -import type { CodexAccountEntry } from "../src/components/codex-account-pool-types"; import type { TFn } from "../src/i18n"; const t: TFn = ((key: string, vars?: Record) => { @@ -12,14 +11,6 @@ let originalFetch: typeof globalThis.fetch; let consumeBody: { code: string; remaining?: number } | null = null; let loadCalls = 0; -const popup: CodexAccountEntry = { - id: "acct-1", - email: "a@example.test", - isMain: false, - hasCredential: true, - quota: { resetCredits: 3, updatedAt: 1 }, -}; - beforeEach(() => { originalFetch = globalThis.fetch; consumeBody = null; @@ -34,9 +25,10 @@ afterEach(() => { Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); }); -test("already_redeemed never invents a decrement when the server omits remaining", async () => { - consumeBody = { code: "already_redeemed" }; - const result = await redeemResetCredit("", "acct-1", t, popup, async () => { +test("balance changed after modal opened: toast uses authoritative remaining, not a stale snapshot", async () => { + // Modal opened when balance was 3; concurrent activity left 1 — server reports 1. + consumeBody = { code: "reset", remaining: 1 }; + const result = await redeemResetCredit("", "acct-1", t, async () => { loadCalls += 1; return true; }); @@ -44,31 +36,45 @@ test("already_redeemed never invents a decrement when the server omits remaining expect(loadCalls).toBe(1); expect(result.ok).toBe(true); expect(result.close).toBe(true); - // Keep the known count; do not toast "2 remaining" from a local guess. - expect(result.toast).toBe("codexAuth.resetSuccess:remaining=3"); + expect(result.toast).toBe("codexAuth.resetSuccess:remaining=1"); expect(result.toast).not.toContain("remaining=2"); + expect(result.toast).not.toContain("remaining=3"); }); -test("already_redeemed prefers the authoritative server remaining count", async () => { +test("already_redeemed does not decrement and uses the returned remaining count", async () => { consumeBody = { code: "already_redeemed", remaining: 3 }; - const result = await redeemResetCredit("", "acct-1", t, popup, async () => true); + const result = await redeemResetCredit("", "acct-1", t, async () => { + loadCalls += 1; + return true; + }); + expect(loadCalls).toBe(1); expect(result.ok).toBe(true); + expect(result.close).toBe(true); expect(result.toast).toBe("codexAuth.resetSuccess:remaining=3"); + expect(result.toast).not.toContain("remaining=2"); +}); + +test("missing refreshed count uses the generic success toast", async () => { + consumeBody = { code: "reset" }; + const result = await redeemResetCredit("", "acct-1", t, async () => true); + + expect(result.ok).toBe(true); + expect(result.toast).toBe("codexAuth.resetSuccessGeneric"); }); -test("already_redeemed without known credits uses the dedicated toast, not a guessed count", async () => { +test("already_redeemed without remaining also uses the generic success toast", async () => { consumeBody = { code: "already_redeemed" }; - const noCredits: CodexAccountEntry = { ...popup, quota: { updatedAt: 1 } }; - const result = await redeemResetCredit("", "acct-1", t, noCredits, async () => true); + const result = await redeemResetCredit("", "acct-1", t, async () => true); expect(result.ok).toBe(true); - expect(result.toast).toBe("codexAuth.resetAlreadyRedeemed"); + expect(result.toast).toBe("codexAuth.resetSuccessGeneric"); + expect(result.toast).not.toBe("codexAuth.resetAlreadyRedeemed"); }); test("failure paths return ok:false so callers can set toastError from result.ok", async () => { consumeBody = { code: "no_credit" }; - const result = await redeemResetCredit("", "acct-1", t, popup, async () => true); + const result = await redeemResetCredit("", "acct-1", t, async () => true); expect(result.ok).toBe(false); expect(result.toast).toBe("codexAuth.resetNoCredit"); diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 6861f9684..9dc907703 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -143,16 +143,9 @@ function safeResetCreditsDto(input: unknown): { credits: { granted_at: string; e }; } -function safeResetCreditConsumeDto(input: unknown): { code: string; remaining?: number } { +function safeResetCreditConsumeDto(input: unknown): { code: string } { const obj = typeof input === "object" && input !== null ? input as Record : {}; - const code = typeof obj.code === "string" ? obj.code : "unknown"; - const rawRemaining = obj.remaining - ?? obj.available_count - ?? (obj.rate_limit_reset_credits as { available_count?: unknown } | null | undefined)?.available_count; - return { - code, - ...(typeof rawRemaining === "number" && Number.isFinite(rawRemaining) ? { remaining: rawRemaining } : {}), - }; + return { code: typeof obj.code === "string" ? obj.code : "unknown" }; } export function isUnverifiedCodexImportEnabled(): boolean { @@ -658,13 +651,22 @@ export async function handleCodexAuthAPI( return jsonResponse({ error: `Upstream error ${resp.status}` }, resp.status); } const result = safeResetCreditConsumeDto(await resp.json()); - if (result.code === "reset") { + // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage + // and return the authoritative remaining credit count from stored quota — never from + // the consume payload or a GUI modal snapshot. + if (result.code === "reset" || result.code === "already_redeemed") { + const quotaAccountId = auth.isMain ? MAIN_CODEX_ACCOUNT_ID : body.accountId; if (auth.isMain) { await fetchMainAccountInfo(true); } else { const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId); await fetchPoolAccountQuota(body.accountId, true, account?.plan); } + const remaining = getAccountQuota(quotaAccountId)?.resetCredits; + return jsonResponse({ + code: result.code, + ...(typeof remaining === "number" && Number.isFinite(remaining) ? { remaining } : {}), + }); } return jsonResponse(result); } catch (e) { diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 5eda106fb..ae8a07ebb 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -651,6 +651,111 @@ describe("codex-auth API", () => { expect(await resp!.json()).toMatchObject({ error: "Invalid account id format" }); }); + test("reset-credit consume returns remaining from refreshed quota, not the consume payload", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-reset", email: "reset@example.test" }); + // Stale local count before redeem — must not be what the response reports. + updateAccountQuota("pool-reset", undefined, undefined, undefined, undefined, 9); + const originalFetch = globalThis.fetch; + let usageCalls = 0; + try { + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + expect(init?.method).toBe("POST"); + // Upstream may advertise a wrong/stale remaining — management must ignore it. + return Response.json({ code: "reset", remaining: 99, available_count: 99 }); + } + if (url.includes("/backend-api/wham/usage")) { + usageCalls += 1; + return Response.json({ + rate_limit: { primary_window: { used_percent: 10, reset_at: 1782000000 } }, + rate_limit_reset_credits: { available_count: 2 }, + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-reset" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset", remaining: 2 }); + expect(usageCalls).toBe(1); + expect(getAccountQuota("pool-reset")?.resetCredits).toBe(2); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reset-credit already_redeemed refreshes quota and never invents a local decrement", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-idempotent", email: "idem@example.test" }); + updateAccountQuota("pool-idempotent", undefined, undefined, undefined, undefined, 3); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + return Response.json({ code: "already_redeemed" }); + } + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + rate_limit: { primary_window: { used_percent: 5, reset_at: 1782000000 } }, + rate_limit_reset_credits: { available_count: 3 }, + }); + } + return originalFetch(input); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-idempotent" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "already_redeemed", remaining: 3 }); + expect(getAccountQuota("pool-idempotent")?.resetCredits).toBe(3); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reset-credit consume omits remaining when refreshed quota has no credit count", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-nocount", email: "nocount@example.test" }); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + return Response.json({ code: "reset", remaining: 1 }); + } + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + rate_limit: { primary_window: { used_percent: 1, reset_at: 1782000000 } }, + }); + } + return originalFetch(input); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-nocount" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset" }); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("unmatched route returns null", async () => { const req = new Request("http://localhost/api/codex-auth/unknown", { method: "GET" }); const url = new URL(req.url); From 3fcdd96cbf9258192f39f54aee1b496b323452a0 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:03:37 +0200 Subject: [PATCH 7/7] fix(codex): omit remaining when post-reset WHAM refresh is not fresh After reset/already_redeemed, return remaining only from a freshly parsed rate_limit_reset_credits.available_count so stale cached credits never leak. --- src/codex/auth-api.ts | 56 ++++++++----- tests/codex-auth-api.test.ts | 149 +++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 18 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 9dc907703..1183ca4e4 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -259,8 +259,15 @@ async function isTerminalMainAuthResponse(resp: Response): Promise { } } +interface MainAccountInfoFetchResult { + info: MainAccountInfo; + /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ + freshResetCredits?: number; +} + export async function fetchMainAccountInfo(forceRefresh = false): Promise { - return fetchMainAccountInfoAttempt(forceRefresh, 1); + const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1); + return info; } const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quota: null }; @@ -268,16 +275,16 @@ const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quot async function retryMainAccountInfoIfIdentityChanged( requestAccountId: string | null, retriesRemaining: number, -): Promise { +): Promise { const currentAccountId = getMainChatgptAccountId(); if (currentAccountId === null || currentAccountId === requestAccountId) return null; reconcileMainCodexAccountRuntimeState(); return retriesRemaining > 0 ? fetchMainAccountInfoAttempt(true, retriesRemaining - 1) - : EMPTY_MAIN_ACCOUNT_INFO; + : { info: EMPTY_MAIN_ACCOUNT_INFO }; } -async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise { +async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise { reconcileMainCodexAccountRuntimeState(); const tokenRead = readCodexTokensResult(); if (tokenRead.status !== "ok") { @@ -288,13 +295,13 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini // routing stays fail-closed because getMainAccountToken() re-reads the file itself, and the // account DTO still reports hasCredential=false while the file is unreadable. const preserved = getMainAccountInfoCache(); - return preserved ?? EMPTY_MAIN_ACCOUNT_INFO; + return { info: preserved ?? EMPTY_MAIN_ACCOUNT_INFO }; } const tokens = tokenRead.tokens; const requestAccountId = extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null); const cached = getMainAccountInfoCache(); if (!forceRefresh && cached && Date.now() - cached.ts < MAIN_CACHE_TTL) { - return cached; + return { info: cached }; } try { const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", { @@ -309,15 +316,17 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini clearMainAccountInfoCache(); markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); } - return EMPTY_MAIN_ACCOUNT_INFO; + return { info: EMPTY_MAIN_ACCOUNT_INFO }; } const data = (await resp.json()) as WhamUsageResponse; const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining); if (retried) return retried; + const quota = parseUsageQuota(data); + const freshResetCredits = quota?.resetCredits; const result = { email: data.email ?? null, plan: data.plan_type ?? null, - quota: parseUsageQuota(data), + quota, ts: Date.now(), }; setMainAccountInfoCache(result); @@ -328,16 +337,21 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini if (result.quota) { setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota); } - return result; + return { + info: result, + ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), + }; } catch { const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining); - return retried ?? EMPTY_MAIN_ACCOUNT_INFO; + return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO }; } } interface PoolQuotaResult { quota: StoredAccountQuota | null; needsReauth: boolean; + /** Present only when this call's WHAM response included `rate_limit_reset_credits.available_count`. */ + freshResetCredits?: number; } export interface CodexAuthAccountDto { @@ -366,9 +380,14 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 }; const data = (await resp.json()) as WhamUsageResponse; const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan }); + const freshResetCredits = quota?.resetCredits; if (!quota) return { quota: existing ?? null, needsReauth: false }; setAccountQuotaFromParsed(accountId, quota); - return { quota: getAccountQuota(accountId), needsReauth: false }; + return { + quota: getAccountQuota(accountId), + needsReauth: false, + ...(freshResetCredits !== undefined ? { freshResetCredits } : {}), + }; } catch (e) { if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false }; if (e instanceof TokenRefreshError) return { quota: existing ?? null, needsReauth: true }; @@ -652,20 +671,21 @@ export async function handleCodexAuthAPI( } const result = safeResetCreditConsumeDto(await resp.json()); // After a successful redeem (or an idempotent already_redeemed), refresh WHAM usage - // and return the authoritative remaining credit count from stored quota — never from - // the consume payload or a GUI modal snapshot. + // and return remaining only when that refresh freshly parsed available_count. + // Do not fall back to a preserved cached resetCredits (failed/omitted refresh). if (result.code === "reset" || result.code === "already_redeemed") { - const quotaAccountId = auth.isMain ? MAIN_CODEX_ACCOUNT_ID : body.accountId; + let freshResetCredits: number | undefined; if (auth.isMain) { - await fetchMainAccountInfo(true); + ({ freshResetCredits } = await fetchMainAccountInfoAttempt(true, 1)); } else { const account = configuredPoolAccount(getRuntimeConfig(config), body.accountId); - await fetchPoolAccountQuota(body.accountId, true, account?.plan); + ({ freshResetCredits } = await fetchPoolAccountQuota(body.accountId, true, account?.plan)); } - const remaining = getAccountQuota(quotaAccountId)?.resetCredits; return jsonResponse({ code: result.code, - ...(typeof remaining === "number" && Number.isFinite(remaining) ? { remaining } : {}), + ...(typeof freshResetCredits === "number" && Number.isFinite(freshResetCredits) + ? { remaining: freshResetCredits } + : {}), }); } return jsonResponse(result); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index ae8a07ebb..ff4791ce0 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -23,6 +23,10 @@ import { import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { + reconcileMainCodexAccountRuntimeState, + resetMainCodexAccountIdentityTrackingForTests, +} from "../src/codex/account-lifecycle"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -114,6 +118,7 @@ beforeEach(() => { clearAccountQuota(); clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID); clearCodexWebSocketRegistry(); + resetMainCodexAccountIdentityTrackingForTests(); }); afterEach(() => { @@ -728,6 +733,8 @@ describe("codex-auth API", () => { test("reset-credit consume omits remaining when refreshed quota has no credit count", async () => { const config = makeConfig(); seedPoolAccount(config, { id: "pool-nocount", email: "nocount@example.test" }); + // Stale cached count must not leak into the response when WHAM omits the field. + updateAccountQuota("pool-nocount", undefined, undefined, undefined, undefined, 7); const originalFetch = globalThis.fetch; try { globalThis.fetch = (async (input: RequestInfo | URL) => { @@ -751,6 +758,148 @@ describe("codex-auth API", () => { const resp = await handleCodexAuthAPI(req, new URL(req.url), config); expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ code: "reset" }); + // Cache may still preserve the prior credit count for other callers. + expect(getAccountQuota("pool-nocount")?.resetCredits).toBe(7); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reset-credit consume omits remaining when pool WHAM refresh is non-2xx", async () => { + const config = makeConfig(); + seedPoolAccount(config, { id: "pool-wham-fail", email: "whamfail@example.test" }); + updateAccountQuota("pool-wham-fail", undefined, undefined, undefined, undefined, 5); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + return Response.json({ code: "reset" }); + } + if (url.includes("/backend-api/wham/usage")) { + return new Response("upstream busy", { status: 503 }); + } + return originalFetch(input); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "pool-wham-fail" }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset" }); + expect(getAccountQuota("pool-wham-fail")?.resetCredits).toBe(5); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reset-credit consume omits remaining when main WHAM refresh is non-2xx", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-reset-fail", account_id: "acct-main-reset-fail" }, + })); + // Stabilize identity tracking so seeding stale quota is not purged by reconcile. + reconcileMainCodexAccountRuntimeState(); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 4); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + return Response.json({ code: "already_redeemed" }); + } + if (url.includes("/backend-api/wham/usage")) { + return new Response("timeout", { status: 504 }); + } + return originalFetch(input); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "already_redeemed" }); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(4); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reset-credit consume omits remaining when main WHAM omits rate_limit_reset_credits", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-reset-omit", account_id: "acct-main-reset-omit" }, + })); + reconcileMainCodexAccountRuntimeState(); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 6); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + return Response.json({ code: "reset" }); + } + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + email: "main@example.test", + plan_type: "pro", + rate_limit: { primary_window: { used_percent: 12, reset_at: 1782000000 } }, + }); + } + return originalFetch(input); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset" }); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(6); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("reset-credit consume returns remaining from fresh main WHAM credits", async () => { + writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({ + tokens: { access_token: "main-reset-ok", account_id: "acct-main-reset-ok" }, + })); + reconcileMainCodexAccountRuntimeState(); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 9); + const originalFetch = globalThis.fetch; + try { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/backend-api/wham/rate-limit-reset-credits/consume")) { + return Response.json({ code: "reset", remaining: 99 }); + } + if (url.includes("/backend-api/wham/usage")) { + return Response.json({ + email: "main@example.test", + plan_type: "pro", + rate_limit: { primary_window: { used_percent: 8, reset_at: 1782000000 } }, + rate_limit_reset_credits: { available_count: 1 }, + }); + } + return originalFetch(input); + }) as typeof fetch; + + const req = new Request("http://localhost/api/codex-auth/reset-credits/consume", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ code: "reset", remaining: 1 }); + expect(getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits).toBe(1); } finally { globalThis.fetch = originalFetch; }