From 4500ea030c05a2ac9a773c9a1c329ebef8860d8e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 09:54:09 +0900 Subject: [PATCH 1/4] docs(devlog): fold wp4 audit residual into L3 design --- .../030_l3_main_card_relogin_ui.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md index 18ed04b9f7..41c1196409 100644 --- a/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md +++ b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md @@ -61,6 +61,21 @@ MODIFY `gui/src/i18n/{en,de,fr,ja,ko,ru,tr,zh,zh-TW}.ts` failure copy (actionable, safe; no auto-retry wording). Revise `mainTokenExpired` so it no longer claims App login is the only path. +## Audit folds (wp4 A) + +- Start POSTs an EMPTY body (the route rejects any body with 400); poll + immediately until verificationUrl/deviceCode arrive (they are empty in the + start response), and keep the last url/code through the committing state. +- Map the full MainDeviceReauthStatus union + HTTP error shapes: committing + (no url/code), failed.code (identity_mismatch, credential_changed, + native_main_unavailable, device_authorization_failed, + publication_failed, reconciliation_failed), 409 flow_in_progress, 503 + native_main_unavailable; when credentialUpdated is true the copy never + claims the file was unchanged; the verification URL is allowlisted to + https://auth.openai.com/codex/device. +- structure claim lands in structure/gui-and-management-api.md (the + Codex-accounts row :312), not overview.md. + ## Tests (red-first) NEW `gui/tests/main-device-reauth.test.tsx` — happy-dom mount per From d111f8d63bc41d23c390bf3a42673568ea7f4593 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 10:02:38 +0900 Subject: [PATCH 2/4] feat(gui): main-card Re-login with device code on the native reauth namespace (#3898) --- gui/src/components/CodexAccountPool.tsx | 12 +- .../codex-account-pool-main-card.tsx | 50 ++++- gui/src/components/use-main-device-reauth.ts | 184 ++++++++++++++++++ gui/src/i18n/de.ts | 9 +- gui/src/i18n/en.ts | 9 +- gui/src/i18n/fr.ts | 9 +- gui/src/i18n/ja.ts | 9 +- gui/src/i18n/ko.ts | 9 +- gui/src/i18n/ru.ts | 9 +- gui/src/i18n/tr.ts | 9 +- gui/src/i18n/zh-TW.ts | 9 +- gui/src/i18n/zh.ts | 9 +- gui/tests/main-device-reauth.test.tsx | 154 +++++++++++++++ scripts/test-layout/layout.json | 3 +- structure/gui-and-management-api.md | 2 +- tests/fixtures/test-layout-expected.json | 3 +- tests/gui/main-device-reauth-ui.test.ts | 57 ++++++ 17 files changed, 531 insertions(+), 15 deletions(-) create mode 100644 gui/src/components/use-main-device-reauth.ts create mode 100644 gui/tests/main-device-reauth.test.tsx create mode 100644 tests/gui/main-device-reauth-ui.test.ts diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index f211f1c689..9e3e58ac32 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -4,6 +4,7 @@ import { IconPlus } from "../icons"; import { EmptyState, type NoticeTone } from "../ui"; import AddCodexAccountModal from "./AddCodexAccountModal"; import { useCodexAccountPool, type CodexAccountPoolController } from "../hooks/useCodexAccountPool"; +import { useMainDeviceReauth } from "./use-main-device-reauth"; import type { ReactNode } from "react"; import type { CodexAccountModeState } from "../codex-multi-state"; import CodexAutoSwitchSetting from "./CodexAutoSwitchSetting"; @@ -71,6 +72,12 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const ownController = useCodexAccountPool(apiBase, !injectedController); const controller = injectedController ?? ownController; const { accounts, activeId, loadState, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller; + // #3898: the native-main device reauth drives the dedicated namespace; a + // completed flow refreshes the account list so the card leaves reauth state. + const mainReauth = useMainDeviceReauth(apiBase, () => { void load(); }); + const mainReauthActive = mainReauth.state.phase === "starting" + || mainReauth.state.phase === "pending" + || mainReauth.state.phase === "committing"; const [confirm, setConfirm] = useState(null); const [showAdd, setShowAdd] = useState(false); const [modelsNotice, setModelsNotice] = useState<{ catalogRefreshPending: boolean } | null>(null); @@ -172,10 +179,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban }, [readLastThreshold, hydrateServerValue]); useEffect(() => { - if (!showAdd) return; + if (!showAdd && !mainReauthActive) return; const token = controller.pauseRefresh(); return () => controller.resumeRefresh(token); - }, [controller, showAdd]); + }, [controller, showAdd, mainReauthActive]); const activePoolAccount = activeId && activeId !== "__main__" ? accounts.find(a => a.id === activeId) @@ -531,6 +538,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban onCopyDoctor={showDoctorCopy ? copyDoctor : undefined} doctorCopyOutcomeFor={showDoctorCopy ? doctorCopy.outcomeFor : undefined} onManageMainHardLock={hasMainHardLockSetting ? manageMainHardLock : undefined} + mainReauth={mainReauth} />
diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index f90756afe0..9f6f58a882 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -6,6 +6,7 @@ import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-he import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; +import type { MainDeviceReauthState } from "./use-main-device-reauth"; import type { NoticeTone } from "../ui"; import { navigateHash } from "../hash-routing"; import { @@ -37,6 +38,7 @@ export function CodexAccountPoolMainCard({ onCopyDoctor, doctorCopyOutcomeFor, onManageMainHardLock, + mainReauth, }: { t: TFn; main: CodexAccountEntry | undefined; @@ -62,6 +64,12 @@ export function CodexAccountPoolMainCard({ onCopyDoctor?: (accountId: string) => void; doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null; onManageMainHardLock?: () => void; + /** #3898: native-main device reauth flow state and controls (dedicated namespace). */ + mainReauth?: { + state: MainDeviceReauthState; + start: () => Promise; + cancel: () => Promise; + } | undefined; }) { const mainFallbackLabel = t("codexAuth.codexApp"); const mainId = main?.id ?? "__main__"; @@ -182,7 +190,47 @@ export function CodexAccountPoolMainCard({
{t("pws.healthCooldownHint")}
)} {showReauth - ?
{t("codexAuth.mainTokenExpired")}
+ ?
+

{t("codexAuth.mainTokenExpired")}

+ {mainReauth && (mainReauth.state.phase === "idle" || mainReauth.state.phase === "failed") && ( + <> + + {mainReauth.state.phase === "failed" && ( + {t("codexAuth.mainReauthFailed")}: {mainReauth.state.code} + )} + + )} + {mainReauth && mainReauth.state.phase === "starting" && ( + {t("codexAuth.mainReauthPending")} + )} + {mainReauth && (mainReauth.state.phase === "pending" || mainReauth.state.phase === "committing") && ( + + {mainReauth.state.verificationUrl && ( + {t("codexAuth.mainReauthOpen")}: {mainReauth.state.verificationUrl} + )} + {mainReauth.state.deviceCode && ( + {t("codexAuth.mainReauthCode")}: {mainReauth.state.deviceCode} + )} + {t("codexAuth.mainReauthPending")} + + + )} + {mainReauth && mainReauth.state.phase === "succeeded" && ( + {t("codexAuth.mainReauthSucceeded")} + )} +
: !inCooldown && <> ([ + "identity_mismatch", + "credential_changed", + "native_main_unavailable", + "device_authorization_failed", + "publication_failed", + "reconciliation_failed", + "flow_in_progress", +]); + +function failureCode(value: unknown): MainDeviceReauthFailureCode { + return typeof value === "string" && FAILURE_CODES.has(value as MainDeviceReauthFailureCode) + ? value as MainDeviceReauthFailureCode + : "request_failed"; +} + +function allowedVerificationUrl(value: unknown): string { + return typeof value === "string" && value.startsWith(DEVICE_VERIFICATION_URL) ? value : ""; +} + +function humanCode(value: unknown): string { + return typeof value === "string" && /^[A-Z0-9-]{1,16}$/i.test(value) ? value : ""; +} + +export function useMainDeviceReauth(apiBase: string, onCompleted: () => void) { + const [state, setState] = useState({ phase: "idle" }); + const flowRef = useRef(null); + const abortRef = useRef(null); + const unmountedRef = useRef(false); + + const stopPolling = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + }, []); + + const cancel = useCallback(async () => { + const flowId = flowRef.current; + stopPolling(); + flowRef.current = null; + if (!flowId) { + setState({ phase: "idle" }); + return; + } + try { + await fetch(`${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`, { method: "DELETE" }); + } catch { /* best-effort: the flow expires on its own */ } + setState({ phase: "cancelled" }); + }, [apiBase, stopPolling]); + + const start = useCallback(async () => { + stopPolling(); + flowRef.current = null; + const ctrl = new AbortController(); + abortRef.current = ctrl; + setState({ phase: "starting" }); + let flowId: string; + try { + // Empty body by contract: the route rejects any request keys with 400. + const res = await fetch(`${apiBase}/api/codex-auth/main/reauth-device`, { method: "POST", signal: ctrl.signal }); + const dto = await res.json().catch(() => ({})) as FlowDto; + if (!res.ok) { + setState({ phase: "failed", code: failureCode(dto.code) }); + return; + } + if (typeof dto.flowId !== "string" || !dto.flowId) { + setState({ phase: "failed", code: "request_failed" }); + return; + } + flowId = dto.flowId; + } catch { + if (!ctrl.signal.aborted) setState({ phase: "failed", code: "request_failed" }); + return; + } + flowRef.current = flowId; + let lastUrl = ""; + let lastCode = ""; + // Poll immediately: the start response predates the usercode reply, so the + // URL and human code only arrive through status reads. + for (;;) { + if (ctrl.signal.aborted || unmountedRef.current || flowRef.current !== flowId) return; + try { + const res = await fetch( + `${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`, + { signal: AbortSignal.any([ctrl.signal, AbortSignal.timeout(POLL_TICK_TIMEOUT_MS)]) }, + ); + const dto = await res.json().catch(() => ({})) as FlowDto; + if (!res.ok) { + setState({ phase: "failed", code: failureCode(dto.code) }); + return; + } + lastUrl = allowedVerificationUrl(dto.verificationUrl) || lastUrl; + lastCode = humanCode(dto.deviceCode) || lastCode; + if (dto.status === "pending" || dto.status === "committing") { + setState({ + phase: dto.status, + flowId, + verificationUrl: lastUrl, + deviceCode: lastCode, + }); + } else if (dto.status === "succeeded") { + flowRef.current = null; + setState({ phase: "succeeded" }); + onCompleted(); + return; + } else if (dto.status === "cancelled") { + flowRef.current = null; + setState({ phase: "cancelled" }); + return; + } else if (dto.status === "failed") { + flowRef.current = null; + setState({ phase: "failed", code: failureCode(dto.code) }); + return; + } + } catch { + if (ctrl.signal.aborted || unmountedRef.current) return; + // A tick failure is transient: the service flow keeps its own deadline. + } + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + } + }, [apiBase, onCompleted, stopPolling]); + + useEffect(() => { + return () => { + unmountedRef.current = true; + stopPolling(); + const flowId = flowRef.current; + flowRef.current = null; + if (flowId) { + void fetch(`${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`, { method: "DELETE" }) + .catch(() => {}); + } + }; + }, [apiBase, stopPolling]); + + return { state, start, cancel }; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index db6b8c889f..68feff3023 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1479,7 +1479,14 @@ export const de: Record = { "codexAuth.needsReauth": "Erneut anmelden", "codexAuth.reauthenticate": "Re-authenticate", "codexAuth.tokenExpired": "Token abgelaufen — dieses Konto erneut authentifizieren", - "codexAuth.mainTokenExpired": "Token abgelaufen — erneut über Codex-App-Login anmelden", + "codexAuth.mainTokenExpired": "Token abgelaufen — unten mit Gerätecode oder über Codex-App-Login erneut anmelden", + "codexAuth.mainReauthSucceeded": "Angemeldet", + "codexAuth.mainReauthFailed": "Anmeldung fehlgeschlagen", + "codexAuth.mainReauthCancel": "Abbrechen", + "codexAuth.mainReauthCode": "Code", + "codexAuth.mainReauthOpen": "Öffnen", + "codexAuth.mainReauthPending": "Warte auf Anmeldung…", + "codexAuth.mainReauthDevice": "Erneut mit Gerätecode anmelden", "codexAuth.emailCollision": "Dieses Konto entspricht deinem Haupt-Codex-Login. Nutze ein anderes Konto.", "codexAuth.resetCreditsTitle": "Gutschriften zurücksetzen", "codexAuth.resetCreditsAvailable": "Du hast {count} Reset-Gutschrift(en) verfügbar.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 74e4ded4d1..63f54b0c32 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2063,7 +2063,14 @@ export const en = { "codexAuth.needsReauth": "Re-login", "codexAuth.reauthenticate": "Re-authenticate", "codexAuth.tokenExpired": "Token expired — re-authenticate this account", - "codexAuth.mainTokenExpired": "Token expired — sign in again via Codex App login", + "codexAuth.mainTokenExpired": "Token expired — re-login with a device code below, or via Codex App login", + "codexAuth.mainReauthSucceeded": "Signed in", + "codexAuth.mainReauthFailed": "Re-login failed", + "codexAuth.mainReauthCancel": "Cancel", + "codexAuth.mainReauthCode": "Code", + "codexAuth.mainReauthOpen": "Open", + "codexAuth.mainReauthPending": "Waiting for sign-in…", + "codexAuth.mainReauthDevice": "Re-login with device code", "codexAuth.emailCollision": "This account matches your main Codex login. Use a different account.", "codexAuth.resetCreditsTitle": "Reset Credits", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2bc3d5ab51..5cf01bcba2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1985,7 +1985,14 @@ export const fr: Record = { "codexAuth.needsReauth": "Se reconnecter", "codexAuth.reauthenticate": "Se réauthentifier", "codexAuth.tokenExpired": "Jeton expiré — réauthentifiez ce compte", - "codexAuth.mainTokenExpired": "Jeton expiré — reconnectez-vous depuis l’application Codex", + "codexAuth.mainTokenExpired": "Jeton expiré — reconnectez-vous avec un code appareil ci-dessous ou via la connexion Codex App", + "codexAuth.mainReauthSucceeded": "Connecté", + "codexAuth.mainReauthFailed": "Échec de la reconnexion", + "codexAuth.mainReauthCancel": "Annuler", + "codexAuth.mainReauthCode": "Code", + "codexAuth.mainReauthOpen": "Ouvrir", + "codexAuth.mainReauthPending": "En attente de connexion…", + "codexAuth.mainReauthDevice": "Reconnectez-vous avec un code appareil", "codexAuth.emailCollision": "Ce compte correspond à votre connexion Codex principale. Utilisez un autre compte.", "codexAuth.resetCreditsTitle": "Crédits de réinitialisation", "codexAuth.resetCreditsAvailable": "Vous disposez de {count} crédit(s) de réinitialisation.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a787ab735b..5971eb8e3c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1914,7 +1914,14 @@ export const ja: Record = { "codexAuth.needsReauth": "再ログイン", "codexAuth.reauthenticate": "再認証", "codexAuth.tokenExpired": "トークンが期限切れ — このアカウントを再認証してください", - "codexAuth.mainTokenExpired": "トークンが期限切れ — Codex アプリログインから再度サインインしてください", + "codexAuth.mainTokenExpired": "トークンの有効期限切れ — 下のデバイスコードまたは Codex アプリログインで再ログインしてください", + "codexAuth.mainReauthSucceeded": "サインインしました", + "codexAuth.mainReauthFailed": "再ログインに失敗しました", + "codexAuth.mainReauthCancel": "キャンセル", + "codexAuth.mainReauthCode": "コード", + "codexAuth.mainReauthOpen": "開く", + "codexAuth.mainReauthPending": "サインインを待っています…", + "codexAuth.mainReauthDevice": "デバイスコードで再ログイン", "codexAuth.emailCollision": "このアカウントはメインの Codex ログインと一致します。別のアカウントを使用してください。", "codexAuth.resetCreditsTitle": "リセットクレジット", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index ccd2b06735..8ae8f56b79 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1515,7 +1515,14 @@ export const ko: Record = { "codexAuth.needsReauth": "재로그인", "codexAuth.reauthenticate": "Re-authenticate", "codexAuth.tokenExpired": "토큰 만료 — 이 계정을 다시 인증하세요", - "codexAuth.mainTokenExpired": "토큰 만료 — Codex 앱 로그인으로 다시 로그인하세요", + "codexAuth.mainTokenExpired": "토큰 만료 — 아래 디바이스 코드 또는 Codex 앱 로그인으로 다시 로그인하세요", + "codexAuth.mainReauthSucceeded": "로그인됨", + "codexAuth.mainReauthFailed": "다시 로그인 실패", + "codexAuth.mainReauthCancel": "취소", + "codexAuth.mainReauthCode": "코드", + "codexAuth.mainReauthOpen": "열기", + "codexAuth.mainReauthPending": "로그인을 기다리는 중…", + "codexAuth.mainReauthDevice": "디바이스 코드로 다시 로그인", "codexAuth.emailCollision": "이 계정은 메인 Codex 로그인과 동일합니다. 다른 계정을 사용하세요.", "codexAuth.resetCreditsTitle": "리셋 크레딧", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ceb0c18a92..277cb49175 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1984,7 +1984,14 @@ export const ru: Record = { "codexAuth.needsReauth": "Повторный вход", "codexAuth.reauthenticate": "Переавторизоваться", "codexAuth.tokenExpired": "Токен истёк — переавторизуйте этот аккаунт", - "codexAuth.mainTokenExpired": "Токен истёк — повторите вход через приложение Codex", + "codexAuth.mainTokenExpired": "Токен истёк — войдите снова по коду устройства ниже или через вход в Codex App", + "codexAuth.mainReauthSucceeded": "Вход выполнен", + "codexAuth.mainReauthFailed": "Не удалось войти снова", + "codexAuth.mainReauthCancel": "Отмена", + "codexAuth.mainReauthCode": "Код", + "codexAuth.mainReauthOpen": "Открыть", + "codexAuth.mainReauthPending": "Ожидание входа…", + "codexAuth.mainReauthDevice": "Войти снова по коду устройства", "codexAuth.emailCollision": "Этот аккаунт совпадает с вашим основным входом Codex. Используйте другой аккаунт.", "codexAuth.resetCreditsTitle": "Кредиты сброса", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 26a8f93d09..f280f08f68 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2004,7 +2004,14 @@ export const tr: Record = { "codexAuth.needsReauth": "Tekrar Giriş Yap", "codexAuth.reauthenticate": "Yeniden Doğrula", "codexAuth.tokenExpired": "Jeton süresi doldu — hesabı yeniden doğrulayın", - "codexAuth.mainTokenExpired": "Jeton süresi doldu — tekrar giriş yapın", + "codexAuth.mainTokenExpired": "Belirteç süresi doldu — aşağıdan cihaz koduyla veya Codex App girişiyle yeniden giriş yapın", + "codexAuth.mainReauthSucceeded": "Giriş yapıldı", + "codexAuth.mainReauthFailed": "Yeniden giriş başarısız", + "codexAuth.mainReauthCancel": "İptal", + "codexAuth.mainReauthCode": "Kod", + "codexAuth.mainReauthOpen": "Aç", + "codexAuth.mainReauthPending": "Giriş bekleniyor…", + "codexAuth.mainReauthDevice": "Cihaz koduyla yeniden giriş yap", "codexAuth.emailCollision": "Bu hesap ana girişinizle eşleşiyor.", "codexAuth.resetCreditsTitle": "Kredileri Sıfırla", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1fb8387f4d..688cf590f5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1524,7 +1524,14 @@ export const zhTW: Record = { "codexAuth.needsReauth": "重新登入", "codexAuth.reauthenticate": "重新認證", "codexAuth.tokenExpired": "權杖已過期 — 請重新認證此帳號", - "codexAuth.mainTokenExpired": "權杖已過期 — 請透過 Codex 應用登入重新登入", + "codexAuth.mainTokenExpired": "權杖已過期 — 請使用下方裝置碼或透過 Codex 應用程式登入重新登入", + "codexAuth.mainReauthSucceeded": "已登入", + "codexAuth.mainReauthFailed": "重新登入失敗", + "codexAuth.mainReauthCancel": "取消", + "codexAuth.mainReauthCode": "驗證碼", + "codexAuth.mainReauthOpen": "開啟", + "codexAuth.mainReauthPending": "等待登入…", + "codexAuth.mainReauthDevice": "使用裝置碼重新登入", "codexAuth.emailCollision": "此帳號與您的主 Codex 登入相同。請使用其他帳號。", "codexAuth.resetCreditsTitle": "重設額度", "codexAuth.resetCreditsAvailable": "您有 {count} 個可用重設額度。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4e2cd831cc..bcf995f4c1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1496,7 +1496,14 @@ export const zh: Record = { "codexAuth.needsReauth": "重新登录", "codexAuth.reauthenticate": "重新认证", "codexAuth.tokenExpired": "令牌已过期 — 请重新认证此账号", - "codexAuth.mainTokenExpired": "令牌已过期 — 请通过 Codex 应用登录重新登录", + "codexAuth.mainTokenExpired": "令牌已过期 — 请使用下方设备码或通过 Codex 应用登录重新登录", + "codexAuth.mainReauthSucceeded": "已登录", + "codexAuth.mainReauthFailed": "重新登录失败", + "codexAuth.mainReauthCancel": "取消", + "codexAuth.mainReauthCode": "验证码", + "codexAuth.mainReauthOpen": "打开", + "codexAuth.mainReauthPending": "等待登录…", + "codexAuth.mainReauthDevice": "使用设备码重新登录", "codexAuth.emailCollision": "此账号与您的主 Codex 登录相同。请使用其他账号。", "codexAuth.resetCreditsTitle": "重置额度", diff --git a/gui/tests/main-device-reauth.test.tsx b/gui/tests/main-device-reauth.test.tsx new file mode 100644 index 0000000000..b7ef4300ed --- /dev/null +++ b/gui/tests/main-device-reauth.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 { LanguageProvider } from "../src/i18n/provider"; +import { CodexAccountPoolMainCard } from "../src/components/codex-account-pool-main-card"; +import { useMainDeviceReauth, type MainDeviceReauthState } from "../src/components/use-main-device-reauth"; +import type { CodexAccountEntry } from "../src/components/codex-account-pool-types"; + +/** + * #3898 L3: the main card gets a device-code Re-login that drives ONLY the + * dedicated native-main namespace. Pool Add/Re-login and the native profile + * picker stay on their own paths. + */ + +const DEVICE_URL = "https://auth.openai.com/codex/device"; +const DEVICE_CODE = "ABCD-1234"; + +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 requests: Array<{ method: string; url: string }>; + +const t = ((key: string) => key) as never; + +function reauthMain(): CodexAccountEntry { + return { id: "__main__", isMain: true, needsReauth: true } as unknown as CodexAccountEntry; +} + +function cardProps(state: MainDeviceReauthState, calls: { starts: number; cancels: number }) { + return { + t, + main: reauthMain(), + isMainActive: false, + accountModeState: null, + threshold: 80, + switchActionLabel: "Switch", + onSwitch: () => {}, + onTogglePause: () => {}, + pauseUpdatingId: null, + pauseBusy: false, + onPriorityChange: () => {}, + priorityUpdatingId: null, + switchingId: null, + mainReauth: { + state, + start: async () => { calls.starts += 1; }, + cancel: async () => { calls.cancels += 1; }, + }, + } as never; +} + +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; + requests = []; + host = win.document.createElement("div"); + win.document.body.appendChild(host); +}); + +afterEach(async () => { + if (root) { const r = root; root = null; await act(async () => r.unmount()); } + host.remove(); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); + for (const k of globals) Object.defineProperty(globalThis, k, { configurable: true, value: previous[k] }); +}); + +async function mount(ui: Parameters[1], state: MainDeviceReauthState): Promise { + const { createRoot } = await import("react-dom/client"); + const { createElement } = await import("react"); + await act(async () => { + root = createRoot(host); + root.render(createElement(LanguageProvider, null, createElement(CodexAccountPoolMainCard, cardProps(state, ui)))); + }); +} + +test("expired main card shows the device Re-login CTA and starts the flow", async () => { + const calls = { starts: 0, cancels: 0 }; + await mount(calls, { phase: "idle" }); + const button = host.querySelector("button.codex-auth-action-btn"); + expect(button).not.toBeNull(); + expect(host.textContent).toContain("codexAuth.mainTokenExpired"); + expect(host.textContent).toContain("codexAuth.mainReauthDevice"); + await act(async () => { (button as HTMLButtonElement).click(); }); + expect(calls.starts).toBe(1); + expect(calls.cancels).toBe(0); +}); + +test("a pending flow shows the URL and human code and cancel owns the flow", async () => { + const calls = { starts: 0, cancels: 0 }; + await mount(calls, { phase: "pending", flowId: "f1", verificationUrl: DEVICE_URL, deviceCode: DEVICE_CODE }); + expect(host.textContent).toContain(DEVICE_URL); + expect(host.textContent).toContain(DEVICE_CODE); + const buttons = Array.from(host.querySelectorAll("button.codex-auth-action-btn")); + const cancel = buttons.find(b => b.textContent?.includes("mainReauthCancel")); + expect(cancel).toBeDefined(); + await act(async () => { (cancel as HTMLButtonElement).click(); }); + expect(calls.cancels).toBe(1); +}); + +test("the hook POSTs an empty body to the dedicated route and polls to success", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ method: init?.method ?? "GET", url }); + if (url.endsWith("/api/codex-auth/main/reauth-device") && init?.method === "POST") { + expect(init?.body).toBeUndefined(); + return Response.json({ flowId: "f1", status: "pending", verificationUrl: "", deviceCode: "" }); + } + if (url.includes("/api/codex-auth/main/reauth-device?flowId=") && init?.method !== "DELETE") { + return Response.json({ flowId: "f1", status: "pending", verificationUrl: DEVICE_URL, deviceCode: DEVICE_CODE }); + } + return Response.json({ flowId: "f1", status: "cancelled" }); + }, + }); + let completed = 0; + let captured: { state: MainDeviceReauthState; start: () => Promise; cancel: () => Promise } | null = null; + const Probe = () => { + captured = useMainDeviceReauth("", () => { completed += 1; }); + return null; + }; + const { createRoot } = await import("react-dom/client"); + const { createElement } = await import("react"); + await act(async () => { + root = createRoot(host); + root.render(createElement(LanguageProvider, null, createElement(Probe))); + }); + expect(captured).not.toBeNull(); + await act(async () => { await captured!.start(); }); + expect(requests[0]).toEqual({ method: "POST", url: "/api/codex-auth/main/reauth-device" }); + expect(JSON.stringify(requests)).not.toContain("/api/codex-auth/login"); + const state = captured!.state; + expect(state.phase).toBe("pending"); + if (state.phase === "pending") { + expect(state.verificationUrl).toBe(DEVICE_URL); + expect(state.deviceCode).toBe(DEVICE_CODE); + } + await act(async () => { await captured!.cancel(); }); + expect(captured!.state.phase).toBe("cancelled"); + expect(requests.some(r => r.method === "DELETE")).toBe(true); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8ef047a4a1..0fd390769d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1373,7 +1373,8 @@ "devin-cli-authmode-migration.test.ts": "providers", "usage-log-ws-stage.test.ts": "usage", "main-device-reauth.test.ts": "codex-integration", - "main-device-reauth-api.test.ts": "codex-integration" + "main-device-reauth-api.test.ts": "codex-integration", + "main-device-reauth-ui.test.ts": "gui" }, "migrated": [ "adapters", diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 82d0ef5d20..7265992c44 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -309,7 +309,7 @@ single forms, and the shell pattern is the part worth keeping stable: | Subagents | Featured-roster selection workspace (`gui/src/components/subagents-workspace/`). | | Combos | Rail, detail panel, and an add flow (`gui/src/components/ComboWorkspace.tsx`). | | Add provider | Catalog browser plus form and OAuth panes (`gui/src/components/provider-catalog/`, `gui/src/components/AddProviderModal.tsx`). The catalog browses four tabs — Accounts, Free, Local, Paid — where Local is a catalog-only bucket peeled out of `bucketPresets` after `presetTier` has classified; the workspace `providerTier` stays three-way, so the rail, the free-paid sort and the Free count still treat a local runtime as free. Search sits above the tabs and reaches every tab at once: while a query is live the list renders all four groups with headings and the strip becomes jump chips with counts rather than a tablist, because moving the selected tab would change the row kind under the user (a preset-select button becomes a login row). ArrowDown from the search input focuses the first enabled result action; if none is available, focus stays in the input. The tab strip wraps within narrow modals. Every nonempty note has a full-text button so narrow rows never hide content permanently; the native note dialog closes during teardown and restores focus to its trigger. Provider notes clamp to two lines and open in full in a stacked native `` owned by `AddProviderModal`, which also owns the search text so its `window` Escape handler can unwind popup, then query, then dialog. | -| Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. | +| Codex accounts | Account pool cards, add-account flow, switch and reset modals (`gui/src/components/CodexAccountPool.tsx`, `gui/src/components/AddCodexAccountModal.tsx`), plus the generic account-targeting picker opt-in on `gui/src/pages/codex-set-multiauth.tsx`. Add/delete/login completion is projected to one boolean before presentation; pending catalog work is a warning, not a failed account mutation. The main card's native-main device reauth (#3898) is owned by `gui/src/components/use-main-device-reauth.ts`: the dedicated `/api/codex-auth/main/reauth-device` namespace only — never the pool login route — with flowId-owned polling, an allowlisted verification URL, and no token fields accepted from payloads. | | Dashboard overview | Overview, Providers, and Models tabs at the page level (`gui/src/pages/Dashboard.tsx`), the 30-day token and coverage stats in the overview head (`gui/src/pages/dashboard-overview-head.tsx`), and the effort-cap, injection, maintenance, sidecar, and memory panels below it (`gui/src/pages/dashboard-overview-panels.tsx`). | Rail selection is component-local state today, so a reload returns to the workspace's default diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index efb1afca69..66da931541 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1205,5 +1205,6 @@ "devin-cli-authmode-migration.test.ts": "providers", "usage-log-ws-stage.test.ts": "usage", "main-device-reauth.test.ts": "codex-integration", - "main-device-reauth-api.test.ts": "codex-integration" + "main-device-reauth-api.test.ts": "codex-integration", + "main-device-reauth-ui.test.ts": "gui" } diff --git a/tests/gui/main-device-reauth-ui.test.ts b/tests/gui/main-device-reauth-ui.test.ts new file mode 100644 index 0000000000..a0d9b3e0da --- /dev/null +++ b/tests/gui/main-device-reauth-ui.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { repoPath } from "../helpers/repo-root"; + +/** + * #3898 L3 source contracts: the main-card device reauth drives ONLY the + * dedicated native-main namespace, every shipped locale carries the new copy, + * and the pool login surface is never touched for __main__. + */ + +const LOCALES = ["en", "de", "fr", "ja", "ko", "ru", "tr", "zh", "zh-TW"] as const; +const NEW_KEYS = [ + "codexAuth.mainReauthDevice", + "codexAuth.mainReauthPending", + "codexAuth.mainReauthOpen", + "codexAuth.mainReauthCode", + "codexAuth.mainReauthCancel", + "codexAuth.mainReauthFailed", + "codexAuth.mainReauthSucceeded", +] as const; + +describe("main device reauth UI contracts (#3898)", () => { + test("the hook drives only the dedicated namespace and never the pool login route", async () => { + const hook = await Bun.file(repoPath("gui/src/components/use-main-device-reauth.ts")).text(); + expect(hook).toContain("/api/codex-auth/main/reauth-device"); + expect(hook).not.toContain(String.fromCharCode(96) + "/api/codex-auth/login" + String.fromCharCode(96)); + expect(hook).not.toContain("/api/codex-auth/login?"); + expect(hook).toContain("https://auth.openai.com/codex/device"); + // No token-shaped fields are ever read off a payload. + expect(hook).not.toContain("access_token"); + expect(hook).not.toContain("refresh_token"); + expect(hook).not.toContain("id_token"); + }); + + test("the main card renders the CTA through the hook, wired by the pool page", async () => { + const [card, pool] = await Promise.all([ + Bun.file(repoPath("gui/src/components/codex-account-pool-main-card.tsx")).text(), + Bun.file(repoPath("gui/src/components/CodexAccountPool.tsx")).text(), + ]); + expect(card).toContain("mainReauth"); + expect(card).toContain("codexAuth.mainReauthDevice"); + expect(card).toContain("codexAuth.mainTokenExpired"); + expect(pool).toContain("useMainDeviceReauth"); + expect(pool).toContain("mainReauth={mainReauth}"); + // The pool modal/add path stays untouched: no reauthAccountId=__main__ anywhere. + expect(pool).not.toContain('reauthAccountId="__main__"'); + }); + + test("every shipped locale carries the new copy", async () => { + for (const locale of LOCALES) { + const text = await Bun.file(repoPath("gui/src/i18n/" + locale + ".ts")).text(); + for (const key of NEW_KEYS) { + expect(text, locale + " missing " + key).toContain(String.fromCharCode(34) + key + String.fromCharCode(34) + ": "); + } + expect(text, locale + " mainTokenExpired").toContain("codexAuth.mainTokenExpired"); + } + }); +}); From b324beba4b0fbd848745877e6a58c022f6dd62a7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 10:53:53 +0900 Subject: [PATCH 3/4] test(gui): select the Re-login CTA by label and stop awaiting a pending flow [skip ci] Both device-reauth GUI tests failed on this branch before the merge. The first clicked button.codex-auth-action-btn, but the always-rendered pause control ships the same class and renders first, so the click hit pause and starts stayed 0; select by label the way the cancel test already does. The second awaited start() to completion while the mock always answers pending, and start() owns the flow until a terminal status, so it timed out at 5s; drive it and wait for the first poll to land instead. Product code is unchanged. --- gui/tests/fr-localization.test.ts | 4 ++++ gui/tests/main-device-reauth.test.tsx | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 89786fac49..cbebf66832 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -136,6 +136,10 @@ const INTENTIONAL_ENGLISH = new Set([ "claudeDesktop.supports1m", "claudeDesktop.effort.supported", // Correct French words whose spelling is identical to English. + // "Code" is the same word in French, and the surrounding device-reauth copy already + // uses it ("code appareil", "Code de l'appareil"). Inventing a different label just + // to make the strings differ would be worse copy for a French reader. + "codexAuth.mainReauthCode", "routing.exclusions", "routing.score", "dash.actions", diff --git a/gui/tests/main-device-reauth.test.tsx b/gui/tests/main-device-reauth.test.tsx index b7ef4300ed..2863aa151e 100644 --- a/gui/tests/main-device-reauth.test.tsx +++ b/gui/tests/main-device-reauth.test.tsx @@ -89,8 +89,11 @@ async function mount(ui: Parameters[1], state: MainDeviceReaut test("expired main card shows the device Re-login CTA and starts the flow", async () => { const calls = { starts: 0, cancels: 0 }; await mount(calls, { phase: "idle" }); - const button = host.querySelector("button.codex-auth-action-btn"); - expect(button).not.toBeNull(); + // The pause control ships the same class and renders first, so select the CTA by its + // label the way the cancel test below already does. + const actions = Array.from(host.querySelectorAll("button.codex-auth-action-btn")); + const button = actions.find(b => b.textContent?.includes("mainReauthDevice")); + expect(button).toBeDefined(); expect(host.textContent).toContain("codexAuth.mainTokenExpired"); expect(host.textContent).toContain("codexAuth.mainReauthDevice"); await act(async () => { (button as HTMLButtonElement).click(); }); @@ -139,7 +142,15 @@ test("the hook POSTs an empty body to the dedicated route and polls to success", root.render(createElement(LanguageProvider, null, createElement(Probe))); }); expect(captured).not.toBeNull(); - await act(async () => { await captured!.start(); }); + // start() owns the flow until a terminal status, and this mock stays pending forever, + // so drive it and wait for the first poll to land instead of awaiting completion. + await act(async () => { + void captured!.start(); + const deadline = Date.now() + 2000; + while (captured!.state.phase !== "pending" && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + }); expect(requests[0]).toEqual({ method: "POST", url: "/api/codex-auth/main/reauth-device" }); expect(JSON.stringify(requests)).not.toContain("/api/codex-auth/login"); const state = captured!.state; From 0c8d9d3b2fa9e9d617fbf4f7fe9cbf849feee003 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 10:58:31 +0900 Subject: [PATCH 4/4] fix(gui): give the reauth poll loop an explicit abort condition [skip ci] oxlint's react-compiler pass lowers a for statement and asserts its init is a variable declaration, so the empty init in for (;;) raised "Invariant: Expected a variable declaration" and blamed the enclosing hook. GUI lint runs in hosted CI, so this failed the gate. The loop body already returns on abort, unmount or a replaced flow id, and nothing follows the loop, so keying the loop on the same abort signal preserves behavior exactly. --- gui/src/components/use-main-device-reauth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/components/use-main-device-reauth.ts b/gui/src/components/use-main-device-reauth.ts index ff903b216c..4699384b24 100644 --- a/gui/src/components/use-main-device-reauth.ts +++ b/gui/src/components/use-main-device-reauth.ts @@ -124,7 +124,7 @@ export function useMainDeviceReauth(apiBase: string, onCompleted: () => void) { let lastCode = ""; // Poll immediately: the start response predates the usercode reply, so the // URL and human code only arrive through status reads. - for (;;) { + while (!ctrl.signal.aborted) { if (ctrl.signal.aborted || unmountedRef.current || flowRef.current !== flowId) return; try { const res = await fetch(