{t("modal.defaultModel")}
{item.defaultModel ?? —}
@@ -448,9 +453,9 @@ export default function ProviderOverview({
: item.authMode === "oauth"
? (displayEmail
? t("pws.loggedInAs", { email: displayEmail })
- : oauth?.loggedIn
+ : oauth?.loggedIn === true
? t("pws.loggedInTitle")
- : t("pws.notLoggedIn"))
+ : oauth?.loggedIn === false ? t("pws.notLoggedIn") : t("startup.unverified"))
: item.hasApiKey
? t("pws.apiKeyConfigured")
: authModeLabel(item, t)}
diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts
index afdb6d8a4cb..1a594d4daf0 100644
--- a/gui/src/hooks/useProviderAccountPools.ts
+++ b/gui/src/hooks/useProviderAccountPools.ts
@@ -122,11 +122,15 @@ export function useProviderAccountPools(deps: {
fetchOauth: () => Promise
;
fetchProviderQuotas: (refresh?: boolean) => Promise;
codexActiveNeedsReauth: boolean;
+ invalidateOauthStatusReads?: (provider: string, blocked?: boolean) => boolean;
+ setOauthStatus?: React.Dispatch>>;
}) {
const {
apiBase, t, config, aliveRef, notify,
- fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth,
+ fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, invalidateOauthStatusReads, setOauthStatus,
} = deps;
+ const accountTarget = useMemo(() => ({ apiBase }), [apiBase]);
+ const [accountStateTarget, setAccountStateTarget] = useState(accountTarget);
const [accountSets, setAccountSets] = useState>({});
const [accountLoadStates, setAccountLoadStates] = useState>({});
const [switchingAccounts, setSwitchingAccounts] = useState>({});
@@ -167,6 +171,7 @@ export function useProviderAccountPools(deps: {
setSwitchingAccounts({});
setKeyPools({});
setAccountLoadStates({});
+ setAccountStateTarget(accountTarget);
});
return () => {
mountedRef.current = false;
@@ -179,7 +184,7 @@ export function useProviderAccountPools(deps: {
for (const controller of requests) controller.abort();
requests.clear();
};
- }, [apiBase]);
+ }, [apiBase, accountTarget]);
// Provider lists this instance has already fetched for. The deferred loads below are deliberately
// uncancellable, and StrictMode double-invokes their effects, so dedupe by list identity here.
const accountSetsKeyRef = useRef(null);
@@ -232,7 +237,8 @@ export function useProviderAccountPools(deps: {
activeAccountId: data.activeAccountId ?? null,
accounts: mergeQuotaRows(rows, current[provider]?.accounts ?? [], false),
} } : current);
- setAccountLoadStates(current => currentRoster() ? { ...current, [provider]: "ready" } : current);
+ setAccountLoadStates(current => currentRoster() ? { ...current,
+ [provider]: selectionMutationsRef.current.has(key) ? "loading" : "ready" } : current);
if (!rows.some(supportsQuotaRead)) return true;
const enrich = async (): Promise => {
@@ -352,13 +358,16 @@ export function useProviderAccountPools(deps: {
if (selectionMutationsRef.current.has(key)) return false;
const generation = (rosterGenerationRef.current[key] ?? 0) + 1;
rosterGenerationRef.current[key] = generation;
- const currentRequest = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase
- && !signal?.aborted && rosterGenerationRef.current[key] === generation && !selectionMutationsRef.current.has(key);
+ const ownsRoster = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase
+ && rosterGenerationRef.current[key] === generation && !selectionMutationsRef.current.has(key);
+ const currentRequest = () => ownsRoster() && !signal?.aborted;
+ if (kind === "oauth") setAccountLoadStates(current => currentRequest() ? { ...current, [provider]: "loading" } : current);
try {
if (kind === "oauth") {
const data = await readRoster<{ activeAccountId?: string | null; accounts?: OAuthAccount[] }>(
`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}`, signal);
- if (!Array.isArray(data.accounts) || !currentRequest()) return false;
+ if (!Array.isArray(data.accounts)) throw new Error("Invalid account roster");
+ if (!currentRequest()) return false;
const rows = selectionRows(data.accounts, data.activeAccountId);
setAccountSets(current => currentRequest() ? { ...current, [provider]: {
activeAccountId: data.activeAccountId === undefined ? rows.find(row => row.active)?.id ?? null : data.activeAccountId,
@@ -375,6 +384,7 @@ export function useProviderAccountPools(deps: {
return true;
} catch {
// A missed invalidation read does not change quota health; recovery retries it.
+ if (kind === "oauth") setAccountLoadStates(current => ownsRoster() ? { ...current, [provider]: "error" } : current);
return false;
}
}));
@@ -385,10 +395,27 @@ export function useProviderAccountPools(deps: {
const key = `${kind === "oauth" ? "oauth" : "key"}:${provider}`;
accountRequestGenerationRef.current[key] = (accountRequestGenerationRef.current[key] ?? 0) + 1;
rosterGenerationRef.current[key] = (rosterGenerationRef.current[key] ?? 0) + 1;
+ if (kind === "oauth" && aliveRef.current && mountedRef.current && serverRef.current === apiBase) {
+ setAccountLoadStates(current => ({ ...current, [provider]: "loading" }));
+ }
// The independent quota generation still owns pending/error flags for surviving IDs.
return key;
};
+ const beginAccountLogout = (provider: string): (() => void) | null => {
+ const key = `oauth:${provider}`;
+ if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase
+ || selectionMutationsRef.current.has(key)) return null;
+ invalidateSelectionReads(provider, "oauth");
+ const owner = Symbol();
+ selectionMutationsRef.current.set(key, owner);
+ return () => {
+ if (selectionMutationsRef.current.get(key) !== owner) return;
+ invalidateSelectionReads(provider, "oauth");
+ selectionMutationsRef.current.delete(key);
+ };
+ };
+
const setAccountPoolThreshold = async (provider: string, threshold: number): Promise => {
if (!aliveRef.current || !mountedRef.current || serverRef.current !== apiBase) return false;
// Pool settings and roster reads describe one server value. Invalidate older reads before
@@ -602,6 +629,7 @@ export function useProviderAccountPools(deps: {
const rememberedRetry = affinityResetRetries(apiBase)[provider];
const target = { provider, accountId };
const mutation = Symbol();
+ invalidateSelectionReads(provider, "oauth");
selectionMutationsRef.current.set(key, mutation);
switchingAccountRef.current.set(provider, target);
setSwitchingAccounts(current => ({ ...current, [provider]: target }));
@@ -632,7 +660,13 @@ export function useProviderAccountPools(deps: {
if (currentRetry()) notify(t("prov.affinityResetPending"), false);
} finally {
receipt();
- if (selectionMutationsRef.current.get(key) === mutation) selectionMutationsRef.current.delete(key);
+ if (selectionMutationsRef.current.get(key) === mutation) {
+ selectionMutationsRef.current.delete(key);
+ if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) {
+ invalidateSelectionReads(provider, "oauth");
+ void refreshAccountRosters({ provider, kind: "oauth" });
+ }
+ }
if (switchingAccountRef.current.get(provider) === target) {
switchingAccountRef.current.delete(provider);
if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) {
@@ -778,12 +812,28 @@ export function useProviderAccountPools(deps: {
tone: "danger",
});
const key = `oauth:${provider}`;
- if (!consented || !aliveRef.current || !mountedRef.current || serverRef.current !== apiBase
- || selectionMutationsRef.current.has(key) || switchingAccountRef.current.has(provider)
+ if (!consented || !aliveRef.current || !mountedRef.current || serverRef.current !== apiBase) return;
+ if (selectionMutationsRef.current.has(key) || switchingAccountRef.current.has(provider)
|| pausingAccountRef.current.has(provider)) return;
invalidateSelectionReads(provider, "oauth");
const mutation = Symbol();
selectionMutationsRef.current.set(key, mutation);
+ const statusBlocked = invalidateOauthStatusReads?.(provider, true) === true;
+ const clearStatus = () => setOauthStatus?.(current => {
+ if (!(provider in current)) return current;
+ const next = { ...current };
+ delete next[provider];
+ return next;
+ });
+ if (statusBlocked) clearStatus();
+ let statusSettled = false;
+ const settleStatus = () => {
+ if (statusSettled || !statusBlocked) return;
+ statusSettled = true;
+ if (invalidateOauthStatusReads?.(provider, false) !== true) return;
+ clearStatus();
+ void fetchOauth().catch(() => {});
+ };
const pending = affinityResetRetries(apiBase)[provider];
const current = () => aliveRef.current && mountedRef.current && serverRef.current === apiBase
&& selectionMutationsRef.current.get(key) === mutation;
@@ -797,8 +847,15 @@ export function useProviderAccountPools(deps: {
else {
result = (await res.json().catch(() => ({})) ?? {}) as typeof result;
partial = result.code === "affinity_reset_not_persisted" && result.retryable === true && result.deletionCommitted === true;
- if (!partial) { if (current()) notify(t("prov.accountRemoveFail", { email: label }), false); return; }
+ if (!partial) {
+ if (current()) {
+ if (result.deletionCommitted === false) notify(t("prov.accountRemoveFail", { email: label }), false);
+ else notify(t("prov.accountRemoveUnverified", { email: label }), false);
+ }
+ return;
+ }
}
+ settleStatus();
committed = true;
if (partial) receipt(() => ({ kind: "reset", accountId: account.id }), true, { kind: "unpersisted", accountId: account.id });
else if (result.affinityResetConfirmed === true) receipt(() => undefined, true, { kind: "confirmed" });
@@ -810,14 +867,18 @@ export function useProviderAccountPools(deps: {
notify(t("prov.accountRemoved", { email: label }), true);
}
await fetchAccountSets([provider]);
- await Promise.all([fetchOauth(), fetchProviderQuotas(true)]);
+ if (!statusBlocked) void fetchOauth().catch(() => {
+ if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) notify(t("pws.accountsLoadFailed"), false);
+ });
+ await fetchProviderQuotas(true);
} catch {
- if (current()) notify(t(committed ? "pws.accountsLoadFailed" : "prov.accountRemoveFail", { email: label }), false);
+ if (current()) notify(t(committed ? "pws.accountsLoadFailed" : "prov.accountRemoveUnverified", { email: label }), false);
} finally {
+ settleStatus();
receipt();
if (selectionMutationsRef.current.get(key) === mutation) {
selectionMutationsRef.current.delete(key);
- if (committed && aliveRef.current && mountedRef.current && serverRef.current === apiBase) {
+ if (aliveRef.current && mountedRef.current && serverRef.current === apiBase) {
invalidateSelectionReads(provider, "oauth");
void refreshAccountRosters({ provider, kind: "oauth" });
}
@@ -861,9 +922,9 @@ export function useProviderAccountPools(deps: {
);
return {
- accountSets, accountLoadStates, switchingAccount, pausingAccount, switchingAccounts, pausingAccounts, pendingAffinityResets, retryAffinityReset, openAccounts, keyPools, addingKeyFor, newKeyValue,
+ accountSets, accountLoadStates: accountStateTarget === accountTarget ? accountLoadStates : {}, switchingAccount, pausingAccount, switchingAccounts, pausingAccounts, pendingAffinityResets, retryAffinityReset, openAccounts, keyPools, addingKeyFor, newKeyValue,
setAccountSets, setAccountLoadStates, setSwitchingAccount, setOpenAccounts, setKeyPools, setAddingKeyFor, setNewKeyValue,
- fetchAccountSets, fetchKeyPools, refreshAccountRosters, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount,
+ fetchAccountSets, fetchKeyPools, refreshAccountRosters, beginAccountLogout, switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, addApiKey, editCredentialAlias, removeAccount,
oauthCardProviders, keyCardProviders, activeAccountNeedsReauth,
};
}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 855aad80b86..f8967c2ffde 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -839,6 +839,8 @@ export const de: Record = {
"subagentSurface.learnMore": "Warum das fehlschlägt",
"prov.logoutOk": "Von {provider} abgemeldet.",
"prov.logoutFail": "Abmeldung von {provider} fehlgeschlagen. Der Kontostatus bleibt unverändert.",
+ "prov.logoutBusy": "Eine Kontoänderung läuft. Warten Sie bis zum Abschluss und versuchen Sie die Abmeldung erneut.",
+ "prov.accountRemoveUnverified": "Es konnte nicht geprüft werden, ob {email} entfernt wurde. Aktualisieren Sie vor einem erneuten Versuch die Kontoliste.",
"prov.removed": "\"{name}\" entfernt.",
"prov.removedDefault": "\"{name}\" entfernt. Standardanbieter ist jetzt \"{defaultProvider}\".",
"prov.removedShadowTarget": "\"{name}\" entfernt. Shadow-Call-Abfangen zielt weiterhin auf \"{model}\"; Hilfsaufrufe schlagen fehl, bis Sie ein anderes Ziel wählen.",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 93c33a27e9d..402585f465d 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -862,6 +862,8 @@ export const en = {
"subagentSurface.learnMore": "Why this fails",
"prov.logoutOk": "Logged out of {provider}.",
"prov.logoutFail": "Could not log out of {provider}. Your account state is unchanged.",
+ "prov.logoutBusy": "An account change is in progress. Wait for it to finish, then try logging out again.",
+ "prov.accountRemoveUnverified": "Could not verify whether {email} was removed. Refresh the account list before retrying.",
"prov.removed": "Removed \"{name}\".",
"prov.removedDefault": "Removed \"{name}\". Default provider is now \"{defaultProvider}\".",
"prov.removedShadowTarget": "Removed \"{name}\". Shadow Call Intercept still targets \"{model}\", so helper calls will fail until you choose another target.",
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 4bac4fb5bdb..4f4522d46fb 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -849,6 +849,8 @@ export const fr: Record = {
"subagentSurface.learnMore": "Pourquoi cet échec",
"prov.logoutOk": "Déconnexion de {provider} réussie.",
"prov.logoutFail": "Impossible de se déconnecter de {provider}. L’état de votre compte n’a pas changé.",
+ "prov.logoutBusy": "Une modification du compte est en cours. Attendez sa fin avant de réessayer de vous déconnecter.",
+ "prov.accountRemoveUnverified": "Impossible de vérifier si {email} a été supprimé. Actualisez la liste des comptes avant de réessayer.",
"prov.removed": "« {name} » supprimé.",
"prov.removedDefault": "« {name} » supprimé. Le fournisseur par défaut est maintenant « {defaultProvider} ».",
"prov.removedShadowTarget": "« {name} » supprimé. L’interception des appels fantômes cible toujours « {model} » : les appels auxiliaires échoueront tant que vous n’aurez pas choisi une autre cible.",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index e17c94b7f89..67a4b17efd7 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -844,6 +844,8 @@ export const ja: Record = {
"subagentSurface.learnMore": "失敗する理由",
"prov.logoutOk": "{provider} からログアウトしました。",
"prov.logoutFail": "{provider} からログアウトできませんでした。アカウント状態は変更されていません。",
+ "prov.logoutBusy": "アカウントを変更中です。完了してからログアウトをもう一度お試しください。",
+ "prov.accountRemoveUnverified": "{email} が削除されたか確認できません。再試行する前にアカウント一覧を更新してください。",
"prov.removed": "\"{name}\" を削除しました。",
"prov.removedDefault": "\"{name}\" を削除しました。既定のプロバイダーは \"{defaultProvider}\" になりました。",
"prov.removedShadowTarget": "\"{name}\" を削除しました。シャドウコール傍受の対象はまだ \"{model}\" のため、別の対象を選ぶまで補助呼び出しは失敗します。",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index bab550b094b..d479248e607 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -848,6 +848,8 @@ export const ko: Record = {
"subagentSurface.learnMore": "실패하는 이유",
"prov.logoutOk": "{provider} 에서 로그아웃했습니다.",
"prov.logoutFail": "{provider}에서 로그아웃하지 못했습니다. 계정 상태는 그대로입니다.",
+ "prov.logoutBusy": "계정 변경이 진행 중이에요. 완료된 후 로그아웃을 다시 시도해 주세요.",
+ "prov.accountRemoveUnverified": "{email} 계정이 삭제되었는지 확인할 수 없어요. 다시 시도하기 전에 계정 목록을 새로고침해 주세요.",
"prov.removed": "\"{name}\" 을(를) 삭제했습니다.",
"prov.removedDefault": "\"{name}\"을(를) 삭제했습니다. 이제 기본 프로바이더는 \"{defaultProvider}\"입니다.",
"prov.removedShadowTarget": "\"{name}\"을(를) 삭제했습니다. 쉐도우 호출 가로채기가 아직 \"{model}\"을(를) 가리키므로, 다른 대상을 고를 때까지 보조 호출이 실패합니다.",
diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts
index 2f8c7b96c62..e83cd0ce044 100644
--- a/gui/src/i18n/pt.ts
+++ b/gui/src/i18n/pt.ts
@@ -863,6 +863,8 @@ export const pt: Record = {
"subagentSurface.learnMore": "Por que isso falha",
"prov.logoutOk": "Sessão de {provider} encerrada.",
"prov.logoutFail": "Não foi possível sair de {provider}. O estado da sua conta não mudou.",
+ "prov.logoutBusy": "Uma alteração de conta está em andamento. Aguarde sua conclusão e tente sair novamente.",
+ "prov.accountRemoveUnverified": "Não foi possível confirmar se {email} foi removido. Atualize a lista de contas antes de tentar novamente.",
"prov.removed": "\"{name}\" removido.",
"prov.removedDefault": "\"{name}\" removido. O provedor padrão agora é \"{defaultProvider}\".",
"prov.removedShadowTarget": "\"{name}\" removido. O Shadow Call Intercept ainda aponta para \"{model}\", então as chamadas auxiliares vão falhar até você escolher outro destino.",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 132bf0e533e..9756389bf17 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -850,6 +850,8 @@ export const ru: Record = {
"subagentSurface.learnMore": "Почему возникает сбой",
"prov.logoutOk": "Выполнен выход из {provider}.",
"prov.logoutFail": "Не удалось выйти из {provider}. Состояние аккаунта не изменилось.",
+ "prov.logoutBusy": "Изменение учётной записи выполняется. Дождитесь завершения и повторите выход.",
+ "prov.accountRemoveUnverified": "Не удалось проверить, удалена ли учётная запись {email}. Обновите список перед повторной попыткой.",
"prov.removed": "Провайдер \"{name}\" удалён.",
"prov.removedDefault": "Провайдер \"{name}\" удалён. Провайдером по умолчанию теперь является \"{defaultProvider}\".",
"prov.removedShadowTarget": "Провайдер \"{name}\" удалён. Перехват теневых вызовов всё ещё направлен на \"{model}\", поэтому вспомогательные вызовы будут завершаться ошибкой, пока вы не выберете другую цель.",
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index db0256de5cf..8c19fbda2da 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -863,6 +863,8 @@ export const tr: Record = {
"subagentSurface.learnMore": "Neden başarısız oluyor",
"prov.logoutOk": "{provider} çıkışı yapıldı.",
"prov.logoutFail": "{provider} çıkışı yapılamadı.",
+ "prov.logoutBusy": "Hesap değişikliği sürüyor. Tamamlanmasını bekleyip tekrar çıkış yapmayı deneyin.",
+ "prov.accountRemoveUnverified": "{email} hesabının kaldırılıp kaldırılmadığı doğrulanamadı. Yeniden denemeden önce hesap listesini yenileyin.",
"prov.removed": "\"{name}\" kaldırıldı.",
"prov.removedDefault": "\"{name}\" kaldırıldı. Varsayılan sağlayıcı artık \"{defaultProvider}\".",
"prov.removedShadowTarget": "\"{name}\" kaldırıldı. Gölge Çağrı Yakalama hâlâ \"{model}\" hedefini kullanıyor; başka bir hedef seçene kadar yardımcı çağrılar başarısız olur.",
diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts
index e2efd284495..7aa47552b73 100644
--- a/gui/src/i18n/vi.ts
+++ b/gui/src/i18n/vi.ts
@@ -849,6 +849,8 @@ export const vi: Record = {
"subagentSurface.learnMore": "Vì sao việc này thất bại",
"prov.logoutOk": "Đã đăng xuất khỏi {provider}.",
"prov.logoutFail": "Không thể đăng xuất khỏi {provider}. Trạng thái tài khoản của bạn vẫn không thay đổi.",
+ "prov.logoutBusy": "Đang thay đổi tài khoản. Hãy đợi hoàn tất rồi thử đăng xuất lại.",
+ "prov.accountRemoveUnverified": "Không thể xác nhận {email} đã được xóa hay chưa. Hãy tải lại danh sách tài khoản trước khi thử lại.",
"prov.removed": "Đã xóa \"{name}\".",
"prov.removedDefault": "Đã xoá \"{name}\". Provider mặc định hiện tại là \"{defaultProvider}\".",
"prov.removedShadowTarget": "Đã xóa \"{name}\". Shadow Call Intercept vẫn trỏ tới \"{model}\", nên các lệnh gọi phụ sẽ thất bại cho đến khi bạn chọn mục tiêu khác.",
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index f6bfa5ab864..7e539620ac6 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -727,6 +727,8 @@ export const zhTW: Record = {
"subagentSurface.learnMore": "為什麼會失敗",
"prov.logoutOk": "已登出 {provider}。",
"prov.logoutFail": "無法登出 {provider}。帳號狀態保持不變。",
+ "prov.logoutBusy": "帳戶變更正在進行。請等待完成後再試著登出。",
+ "prov.accountRemoveUnverified": "無法確認是否已移除 {email}。請重新整理帳戶清單後再試。",
"prov.removed": "已移除 \"{name}\"。",
"prov.removeFail": "移除 \"{name}\" 失敗。",
"prov.added": "已新增 \"{name}\"。現已生效 — 執行 {cmd}(或重新啟動)以在 Codex 選擇器中列出其模型。",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index aa3a52e8191..b43ca2ca51c 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -845,6 +845,8 @@ export const zh: Record = {
"subagentSurface.learnMore": "为什么失败",
"prov.logoutOk": "已退出 {provider}。",
"prov.logoutFail": "无法退出 {provider}。账户状态保持不变。",
+ "prov.logoutBusy": "账户更改正在进行。请等待完成后重试退出登录。",
+ "prov.accountRemoveUnverified": "无法确认是否已移除 {email}。请刷新账户列表后再试。",
"prov.removed": "已移除 \"{name}\"。",
"prov.removedDefault": "已移除 \"{name}\"。默认提供方现为 \"{defaultProvider}\"。",
"prov.removedShadowTarget": "已移除 \"{name}\"。影子调用拦截仍指向 \"{model}\",在选择其他目标之前,辅助调用会失败。",
diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx
index 4e9cae60efd..6c6fd7ff14c 100644
--- a/gui/src/pages/Providers.tsx
+++ b/gui/src/pages/Providers.tsx
@@ -1,3 +1,4 @@
+import { useTargetOAuthState } from "./use-target-oauth-state";
import { readUsageResponseJson, usageSummary30dResourceKey } from "../usage-summary-resource";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ProviderWorkspaceShell, { type AddProviderIntent } from "../components/provider-workspace/ProviderWorkspaceShell";
@@ -225,8 +226,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
const [statusTone, setStatusTone] = useState("err");
/** Bumped on every notify so repeated identical success toasts restart the dismiss timer. */
const [statusRevision, setStatusRevision] = useState(0);
- const [oauthProviders, setOauthProviders] = useState([]);
- const [oauthStatus, setOauthStatus] = useState>({});
+ const { oauthProviders, setOauthProviders, oauthStatus, setOauthStatus } = useTargetOAuthState(apiBase);
const [busy, setBusy] = useState(null);
const [loginInfo, setLoginInfo] = useState<{ provider: string; url?: string; instructions?: string; deviceCode?: string; browserLaunch?: BrowserLaunch } | null>(null);
const [workspaceSelected, setWorkspaceSelected] = useState(null);
@@ -338,7 +338,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
* is silent while every real mutation path still forces a re-read.
*/
const { quotaRefresh, invalidateProviderQuotas, settleQuotaRefresh, beginQuotaRefresh } = useQuotaRefreshCoordinator(apiBase);
- const { fetchConfig: refreshConfigResult, fetchOauth, fetchProviderQuotas } = useProvidersFetch({
+ const { fetchConfig: refreshConfigResult, fetchOauth, fetchProviderQuotas, invalidateOauthStatusReads, captureOauthStatusRead } = useProvidersFetch({
apiBase, t, setConfig, setOauthProviders, setOauthStatus, notify,
invalidateProviderQuotas,
configCacheKey,
@@ -384,10 +384,10 @@ export default function Providers({ apiBase }: { apiBase: string }) {
apiBase, t: t as unknown as Parameters[0]["t"],
config, oauthStatus: oauthStatusWithCodex, aliveRef,
notify,
- fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth,
+ fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, invalidateOauthStatusReads, setOauthStatus,
});
const {
- accountSets, setAccountSets, accountLoadStates, switchingAccounts, pausingAccounts, keyPools, fetchAccountSets, fetchKeyPools,
+ accountSets, setAccountSets, accountLoadStates, switchingAccounts, pausingAccounts, keyPools, fetchAccountSets, fetchKeyPools, beginAccountLogout,
refreshAccountRosters, oauthCardProviders, keyCardProviders,
switchAccount, pauseAccount, setAccountPoolThreshold, setAccountThreshold, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias,
removeAccount, activeAccountNeedsReauth,
@@ -479,7 +479,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
const { cancelLoginOAuth, loginOAuth, logoutOAuth, onNativeLoginSettled } = useProvidersOAuth({
apiBase, t, aliveRef, accountSets, setAccountSets,
- setBusy, setStatus, setLoginInfo, setOauthStatus, notify,
+ setBusy, setStatus, setLoginInfo, setOauthStatus, invalidateOauthStatusReads, captureOauthStatusRead, beginAccountLogout, notify,
fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh,
onLoginSettled: onProviderLoginSettled,
});
diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts
index 5b7d8632eba..7327ae727b1 100644
--- a/gui/src/pages/use-providers-fetch.ts
+++ b/gui/src/pages/use-providers-fetch.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import type { TFn } from "../i18n/shared";
import { readJsonIfOk, readJsonOrThrow } from "../fetch-json";
import { writeSessionListCache } from "../session-list-cache";
@@ -27,7 +27,33 @@ export function useProvidersFetch({
configCacheKey?: string;
}) {
const configRequest = useRef(0);
+ const oauthDiscovery = useRef(0);
+ const oauthDiscoveryPublished = useRef(0);
+ const target = useMemo(() => ({ apiBase }), [apiBase]);
+ const activeTarget = useRef(null);
+ const oauthWrites = useRef(new Map());
+ const oauthReads = useRef(new Map());
+ const oauthPublished = useRef(new Map());
+ const oauthAdmitted = useRef(new Set());
+ const oauthBlocked = useRef(new Set());
useEffect(() => () => { configRequest.current += 1; }, [apiBase]);
+ useLayoutEffect(() => {
+ activeTarget.current = target;
+ const blocked = oauthBlocked.current;
+ return () => { activeTarget.current = null; oauthDiscovery.current += 1; oauthAdmitted.current.clear(); blocked.clear(); };
+ }, [target]);
+ const invalidateOauthStatusReads = useCallback((provider: string, blocked?: boolean) => {
+ if (activeTarget.current !== target || (blocked === undefined && oauthBlocked.current.has(provider))) return false;
+ oauthWrites.current.set(provider, (oauthWrites.current.get(provider) ?? 0) + 1);
+ if (blocked === true) oauthBlocked.current.add(provider);
+ if (blocked === false) oauthBlocked.current.delete(provider);
+ return true;
+ }, [target]);
+ const captureOauthStatusRead = useCallback((provider: string) => {
+ const revision = oauthWrites.current.get(provider) ?? 0;
+ return () => activeTarget.current === target && !oauthBlocked.current.has(provider)
+ && (oauthWrites.current.get(provider) ?? 0) === revision;
+ }, [target]);
const fetchConfig = useCallback(async (): Promise => {
const request = ++configRequest.current;
try {
@@ -46,20 +72,34 @@ export function useProvidersFetch({
}, [apiBase, configCacheKey, notify, setConfig, t]);
const fetchOauth = useCallback(async () => {
+ if (activeTarget.current !== target) return;
+ const discovery = ++oauthDiscovery.current;
+ const writes = new Map(oauthWrites.current);
+ const unchanged = (provider: string) => (writes.get(provider) ?? 0) === (oauthWrites.current.get(provider) ?? 0);
try {
// Codex openai status is owned by useCodexAccountPool — do not duplicate /accounts.
const provRes = await fetch(`${apiBase}/api/oauth/providers`);
const provData = await readJsonOrThrow<{ providers?: string[] }>(provRes);
- const provs: string[] = provData?.providers ?? [];
+ if (!Array.isArray(provData?.providers) || !provData.providers.every(p => typeof p === "string")) return;
+ const provs = provData.providers;
+ if (activeTarget.current !== target || discovery < oauthDiscoveryPublished.current) return;
+ oauthDiscoveryPublished.current = discovery;
+ oauthAdmitted.current = new Set(provs);
setOauthProviders(provs);
- const oauthEntries = await Promise.all(provs.map(async p => {
+ setOauthStatus(current => Object.fromEntries(Object.entries(current).filter(([provider]) => oauthAdmitted.current.has(provider) || !unchanged(provider))));
+ await Promise.all(provs.map(async p => {
+ const read = (oauthReads.current.get(p) ?? 0) + 1;
+ oauthReads.current.set(p, read);
const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${encodeURIComponent(p)}`).catch(() => null);
- const s = sRes ? (await readJsonIfOk(sRes) ?? { loggedIn: false }) : { loggedIn: false };
- return [p, s] as const;
+ const s = sRes ? await readJsonIfOk(sRes) : null;
+ if (!s || typeof s.loggedIn !== "boolean") return;
+ if (activeTarget.current !== target || !oauthAdmitted.current.has(p) || oauthBlocked.current.has(p) || !unchanged(p) || read < (oauthPublished.current.get(p) ?? 0)) return;
+ oauthPublished.current.set(p, read);
+ setOauthStatus(current => activeTarget.current === target && oauthAdmitted.current.has(p) && !oauthBlocked.current.has(p) && unchanged(p)
+ && read === oauthPublished.current.get(p) ? { ...current, [p]: s } : current);
}));
- setOauthStatus(Object.fromEntries(oauthEntries));
} catch { /* ignore */ }
- }, [apiBase, setOauthProviders, setOauthStatus]);
+ }, [apiBase, target, setOauthProviders, setOauthStatus]);
/*
* The workspace shell owns the single quota read; this only invalidates it. Keeping the
@@ -70,5 +110,5 @@ export function useProvidersFetch({
invalidateProviderQuotas(refresh);
}, [invalidateProviderQuotas]);
- return { fetchConfig, fetchOauth, fetchProviderQuotas };
+ return { fetchConfig, fetchOauth, fetchProviderQuotas, invalidateOauthStatusReads, captureOauthStatusRead };
}
diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts
index ac66bd33b85..0ca7a6ac1a2 100644
--- a/gui/src/pages/use-providers-oauth.ts
+++ b/gui/src/pages/use-providers-oauth.ts
@@ -20,6 +20,9 @@ export function useProvidersOAuth({
setStatus,
setLoginInfo,
setOauthStatus,
+ invalidateOauthStatusReads,
+ captureOauthStatusRead,
+ beginAccountLogout,
notify,
fetchConfig,
fetchOauth,
@@ -37,6 +40,9 @@ export function useProvidersOAuth({
setStatus: React.Dispatch>;
setLoginInfo: React.Dispatch>;
setOauthStatus: React.Dispatch>>;
+ invalidateOauthStatusReads?: (provider: string, blocked?: boolean) => boolean;
+ captureOauthStatusRead?: (provider: string) => () => boolean;
+ beginAccountLogout?: (provider: string) => (() => void) | null;
notify: (msg: string, ok: boolean) => void;
fetchConfig: () => Promise;
fetchOauth: () => Promise;
@@ -161,13 +167,16 @@ export function useProvidersOAuth({
for (let i = 0; i < loginPollAttempts(deviceFlow, 2000, 150) && aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation; i++) {
await new Promise(r => setTimeout(r, 2000));
if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return;
+ const isCurrentStatusRead = captureOauthStatusRead?.(provider);
const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${provider}`).catch(() => null);
const s: (OAuthStatus & { accounts?: OAuthAccount[]; activeAccountId?: string | null }) | null = sRes
? ((await readJsonIfOk(sRes)) ?? null)
: null;
if (!aliveRef.current || oauthLoginGenerationRef.current!.get(provider) !== generation) return;
+ if (isCurrentStatusRead && !isCurrentStatusRead()) continue;
if (!s) continue;
if (s.error) {
+ if (invalidateOauthStatusReads?.(provider) === false) return;
setOauthStatus(prev => ({ ...prev, [provider]: s }));
const cancelled = /cancel/i.test(s.error);
notify(
@@ -185,6 +194,7 @@ export function useProvidersOAuth({
? (statusCount > baselineCount || s.done === true)
: (s.loggedIn || s.done === true);
if (completed) {
+ if (invalidateOauthStatusReads?.(provider) === false) return;
setOauthStatus(prev => ({ ...prev, [provider]: s }));
const target = reauthTargetId
? s.accounts?.find(a => a.id === reauthTargetId)
@@ -251,26 +261,53 @@ export function useProvidersOAuth({
if (activeLoginGenerationsRef.current.get(provider) === generation) {
activeLoginGenerationsRef.current.delete(provider);
}
- if (aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation) setBusy(null);
+ if (aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation) setBusy(current => current === provider ? null : current);
}
};
const logoutOAuth = async (provider: string) => {
+ const finishLogout = beginAccountLogout?.(provider);
+ if (beginAccountLogout && !finishLogout) {
+ if (aliveRef.current) notify(t("prov.logoutBusy"), false);
+ return;
+ }
+ if (invalidateOauthStatusReads?.(provider, true) === false) { finishLogout?.(); return; }
+ const clearStatus = () => setOauthStatus(current => {
+ if (!(provider in current)) return current;
+ const next = { ...current };
+ delete next[provider];
+ return next;
+ });
+ clearStatus();
// Invalidate any in-flight login poll so a late completion cannot reseed accounts.
bumpLoginGeneration(provider);
setBusy(current => current === provider ? null : current);
setLoginInfo(current => current?.provider === provider ? null : current);
+ let success = false;
try {
const res = await fetch(`${apiBase}/api/oauth/logout?provider=${encodeURIComponent(provider)}`, { method: "POST" });
if (!res.ok) {
notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false);
- return;
+ } else {
+ success = true;
}
+ } catch {
+ notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false);
+ } finally {
+ finishLogout?.();
+ }
+ // A read started while the server was processing logout can still describe
+ // the old account. Only a read started after settlement can verify status.
+ if (invalidateOauthStatusReads?.(provider, false) === false) return;
+ clearStatus();
+ if (!success) {
+ void fetchAccountSets([provider]).catch(() => {});
+ void fetchOauth();
+ return;
+ }
+ try {
await Promise.all([
- fetchAccountSets([provider]),
- fetchOauth(),
- fetchConfig(),
- fetchProviderQuotas(true),
+ fetchAccountSets([provider]), fetchOauth(), fetchConfig(), fetchProviderQuotas(true),
]);
bumpModelsRefresh();
notify(t("prov.logoutOk", { provider: oauthLabel(provider) }), true);
diff --git a/gui/src/pages/use-target-oauth-state.ts b/gui/src/pages/use-target-oauth-state.ts
new file mode 100644
index 00000000000..a0b693cc0ba
--- /dev/null
+++ b/gui/src/pages/use-target-oauth-state.ts
@@ -0,0 +1,24 @@
+import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
+import type { OAuthStatus } from "./providers-shared";
+
+type OAuthSnapshot = { target: object; providers: string[]; status: Record };
+
+// Each visit gets a new identity, including A -> B -> A. Old async writers cannot
+// update the current visit, and the render that changes target sees empty state.
+export function useTargetOAuthState(apiBase: string) {
+ const target = useMemo(() => ({ apiBase }), [apiBase]);
+ const [snapshot, setSnapshot] = useState(() => ({ target, providers: [], status: {} }));
+ const current: OAuthSnapshot = snapshot.target === target ? snapshot : { target, providers: [], status: {} };
+ if (snapshot !== current) setSnapshot(current);
+ const setOauthProviders = useCallback>>(update => {
+ setSnapshot(previous => previous.target !== target ? previous : {
+ ...previous, providers: typeof update === "function" ? update(previous.providers) : update,
+ });
+ }, [target]);
+ const setOauthStatus = useCallback>>>(update => {
+ setSnapshot(previous => previous.target !== target ? previous : {
+ ...previous, status: typeof update === "function" ? update(previous.status) : update,
+ });
+ }, [target]);
+ return { oauthProviders: current.providers, oauthStatus: current.status, setOauthProviders, setOauthStatus };
+}
diff --git a/gui/tests/anthropic2-provider-surfaces.test.tsx b/gui/tests/anthropic2-provider-surfaces.test.tsx
index bc973850cba..28617b4053b 100644
--- a/gui/tests/anthropic2-provider-surfaces.test.tsx
+++ b/gui/tests/anthropic2-provider-surfaces.test.tsx
@@ -13,11 +13,14 @@ import { en } from "../src/i18n/en";
import { LanguageProvider } from "../src/i18n/provider";
import { useT, type TFn } from "../src/i18n/shared";
import ProviderAuthPanel from "../src/components/provider-workspace/ProviderAuthPanel";
+import ProviderDetails from "../src/components/provider-workspace/ProviderDetails";
+import { renderToStaticMarkup } from "react-dom/server";
import CatalogAccountRow from "../src/components/provider-catalog/CatalogAccountRow";
import type { OAuthAccountRow, ProviderAuthHandlers } from "../src/components/provider-workspace/types";
import type { WorkspaceItem } from "../src/provider-workspace/catalog";
import { useProviderAccountPools, type Config as PoolHookConfig } from "../src/hooks/useProviderAccountPools";
import { useProvidersOAuth } from "../src/pages/use-providers-oauth";
+import { useProvidersFetch } from "../src/pages/use-providers-fetch";
import { buildAddModalAccountRows } from "../src/pages/providers-page-utils";
import type { OAuthAccount, OAuthStatus, ProvidersConfig } from "../src/pages/providers-shared";
import { oauthTosCopyKeys, oauthTosRisk } from "../src/oauth-tos-risk";
@@ -364,6 +367,24 @@ test("UX-05: equal account IDs keep pool-keyed rosters and thresholds in the acc
});
for (const item of [POOL_A, POOL_B]) {
+ test(`${item.name} Overview shows only a verified current account label`, () => {
+ const renderOverview = (rows: OAuthAccountRow[], accountLoadState: "idle" | "loading" | "ready" | "error", loggedIn: boolean) => renderToStaticMarkup(
+ {}} onDeselect={() => {}} apiBase="" />,
+ );
+ const rows = accounts(item.name).map((account, index) => ({ ...account,
+ alias: index === 0 ? "Work" : undefined, maskedLabel: index === 0 ? "s***d@example.test" : "o***r@example.test" }));
+ expect(renderOverview(rows, "ready", true)).toContain("Current accountWork");
+ expect(renderOverview(rows.map(row => ({ ...row, alias: undefined })), "ready", true)).toContain("Current accounts***d@example.test");
+ expect(renderOverview(rows.map((row, index) => ({ ...row, alias: undefined, active: index === 1 })), "ready", true)).toContain("Current accounto***r@example.test");
+ expect(renderOverview(rows.map(row => ({ ...row, alias: undefined, maskedLabel: undefined })), "ready", true)).toContain("Current accountAccount 1");
+ for (const state of ["idle", "loading", "error"] as const) expect(renderOverview(rows, state, true)).not.toContain("Current accountWork");
+ const loggedOut = renderOverview(rows, "ready", false);
+ expect(loggedOut).toContain("Current accountWork");
+ expect(loggedOut).not.toContain("Logged in as Work");
+ expect(loggedOut).not.toContain(`raw@${item.name}.test`);
+ });
test(`${item.name} account rows prefer aliases and masked labels without rendering raw email`, async () => {
respond = async request => request.path === "/api/pool/settings"
? Response.json(poolSettings(item.name, false, 55)) : Response.json(grantSnapshot(item.name, 1));
@@ -418,3 +439,172 @@ for (const item of [POOL_A, POOL_B, CUSTOM_B, { ...POOL_A, name: "cursor", adapt
});
}
}
+
+test.each(["switch", "pause"] as const)("logout explains a pending %s and succeeds after it settles", async action => {
+ let release!: (response: Response) => void;
+ let pendingStarted!: () => void;
+ const started = new Promise(resolve => { pendingStarted = resolve; });
+ const notices: string[] = [];
+ respond = async request => {
+ if (request.method === "PUT" && request.path.startsWith("/api/oauth/accounts/")) {
+ pendingStarted(); return new Promise(resolve => { release = resolve; });
+ }
+ if (request.path === "/api/oauth/logout") return Response.json({ ok: true });
+ if (request.path === "/api/oauth/accounts") return Response.json({ activeAccountId: "shared", accounts: accounts("anthropic2") });
+ if (request.path === "/api/pool/settings") return Response.json(poolSettings("anthropic2", false, 55));
+ if (request.path === "/api/anthropic/reset-grants") return Response.json(grantSnapshot("anthropic2", 1));
+ return Response.json({});
+ };
+ function Harness() {
+ const aliveRef = useRef(true);
+ const [status, setOauthStatus] = useState>({ anthropic2: { loggedIn: true } });
+ const pools = useProviderAccountPools({ apiBase: "", config: null, aliveRef, t: key => key,
+ oauthStatus: status, notify: message => notices.push(message), fetchConfig: async () => {},
+ fetchOauth: async () => {}, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false });
+ const oauth = useProvidersOAuth({ apiBase: "", t: key => key, aliveRef, accountSets: pools.accountSets,
+ setAccountSets: pools.setAccountSets, setBusy: () => {}, setStatus: () => {}, setLoginInfo: () => {}, setOauthStatus,
+ beginAccountLogout: pools.beginAccountLogout, notify: message => notices.push(message), fetchConfig: async () => {},
+ fetchOauth: async () => {}, fetchAccountSets: pools.fetchAccountSets, fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {} });
+ return ;
+ }
+ await render();
+ if (action === "switch") await click(host.querySelectorAll(".pwi-auth-row-main")[1]);
+ else await click(host.querySelectorAll('button[aria-label^="' + en["codexAuth.pause"] + ' — "]')[1]);
+ await started;
+ await click(buttonsByText(en["prov.logout"])[0]);
+ expect(requests.filter(request => request.path === "/api/oauth/logout")).toHaveLength(0);
+ expect(notices).toContain("prov.logoutBusy");
+ await act(async () => { release(Response.json({ activeAccountId: "other", activeAccountChanged: false })); });
+ await tick(); await tick();
+ await click(buttonsByText(en["prov.logout"])[0]);
+ expect(requests.filter(request => request.path === "/api/oauth/logout")).toHaveLength(1);
+});
+
+test("unverified OAuth status never reports a stale roster as logged in or logged out", async () => {
+ respond = async request => request.path === "/api/pool/settings"
+ ? Response.json(poolSettings("anthropic2", false, 55)) : Response.json(grantSnapshot("anthropic2", 1));
+ await render();
+ expect(host.querySelector(".pwi-auth-status-text")?.textContent).toBe(en["startup.unverified"]);
+ await render();
+ expect(host.querySelector(".pwi-auth-status-text")?.textContent).toBe(en["startup.unverified"]);
+ await render();
+ expect(host.querySelector(".pwi-auth-status-text")?.textContent).toBe(en["pws.notLoggedInTitle"]);
+});
+
+test.each(["delete", "logout"] as const)("%s with failed reads retains explicit logout without claiming authentication", async operation => {
+ let pools!: ReturnType;
+ let failReads = false;
+ const notices: string[] = [];
+ respond = async request => {
+ if (request.method === "DELETE") { failReads = true; return Response.json({}); }
+ if (request.path === "/api/oauth/logout") { failReads = true; return new Response(null, { status: 500 }); }
+ if (request.path === "/api/oauth/accounts") return failReads ? new Response(null, { status: 503 }) : Response.json({ activeAccountId: "shared", accounts: accounts("anthropic2") });
+ if (request.path === "/api/oauth/providers") return Response.json({ providers: ["anthropic2", "anthropic"] });
+ if (request.path === "/api/oauth/status") return failReads && request.provider === "anthropic2" ? new Response(null, { status: 503 }) : Response.json({ loggedIn: true });
+ if (request.path === "/api/pool/settings") return Response.json(poolSettings("anthropic2", false, 55));
+ return Response.json(grantSnapshot("anthropic2", 1));
+ };
+ function Harness() {
+ const aliveRef = useRef(true);
+ const [status, setOauthStatus] = useState>({ anthropic2: { loggedIn: true }, anthropic: { loggedIn: true } });
+ const loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} });
+ pools = useProviderAccountPools({ apiBase: "", config: null, aliveRef, t: key => key, oauthStatus: status, notify: message => notices.push(message),
+ fetchConfig: async () => {}, fetchOauth: loader.fetchOauth, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false,
+ invalidateOauthStatusReads: loader.invalidateOauthStatusReads, setOauthStatus });
+ const oauth = useProvidersOAuth({ apiBase: "", t: key => key, aliveRef, accountSets: pools.accountSets, setAccountSets: pools.setAccountSets,
+ setBusy: () => {}, setStatus: () => {}, setLoginInfo: () => {}, setOauthStatus, invalidateOauthStatusReads: loader.invalidateOauthStatusReads,
+ beginAccountLogout: pools.beginAccountLogout, notify: message => notices.push(message), fetchConfig: async () => {}, fetchOauth: loader.fetchOauth,
+ fetchAccountSets: pools.fetchAccountSets, fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {} });
+ return <>>;
+ }
+ await render();
+ await act(async () => { await pools.fetchAccountSets(["anthropic2"]); });
+ if (operation === "delete") {
+ await click(host.querySelector(".pwi-auth-row-remove"));
+ await click([...win.document.querySelectorAll("dialog button")].find(button => button.textContent === "common.remove"));
+ } else await click(buttonsByText(en["prov.logout"])[0]);
+ await tick();
+ expect(host.querySelector(".pwi-auth-status-text")?.textContent).toBe(en["startup.unverified"]);
+ expect(host.querySelector("[data-other]")?.textContent).toBe("true");
+ const logoutCount = requests.filter(request => request.path === "/api/oauth/logout").length;
+ await click(buttonsByText(en["prov.logout"])[0]);
+ const logouts = requests.filter(request => request.path === "/api/oauth/logout");
+ expect(logouts).toHaveLength(logoutCount + 1);
+ expect(logouts.every(request => request.provider === "anthropic2")).toBe(true);
+});
+
+test("login cancellation is offered only after the provider supplies a hint", async () => {
+ respond = async request => request.path === "/api/pool/settings"
+ ? Response.json(poolSettings("anthropic2", false, 55)) : Response.json(grantSnapshot("anthropic2", 1));
+ const handlers = { ...recordingHandlers([]), onCancelLogin: () => {} };
+ await render();
+ expect(buttonsByText(en["common.cancel"])).toHaveLength(0);
+ await render();
+ expect(buttonsByText(en["common.cancel"])).toHaveLength(1);
+});
+
+test("pending login cancellation does not prevent logout or account removal", async () => {
+ const { spyOn } = await import("bun:test");
+ const dialogs = await import("../src/action-dialogs");
+ const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true);
+ const login = Promise.withResolvers();
+ const cancel = Promise.withResolvers();
+ let oauth!: ReturnType;
+ let pools!: ReturnType;
+ respond = async request => {
+ if (request.path === "/api/oauth/login") return login.promise;
+ if (request.path === "/api/oauth/login/cancel") return cancel.promise;
+ return Response.json({ activeAccountId: null, accounts: [] });
+ };
+ function Harness() {
+ const aliveRef = useRef(true);
+ const [statuses, setOauthStatus] = useState>({});
+ pools = useProviderAccountPools({ apiBase: "", config: null, aliveRef, t: key => key, oauthStatus: statuses,
+ notify: () => {}, fetchConfig: async () => {}, fetchOauth: async () => {},
+ fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false });
+ oauth = useProvidersOAuth({ apiBase: "", t: key => key, aliveRef, accountSets: pools.accountSets,
+ setAccountSets: pools.setAccountSets, setBusy: () => {}, setStatus: () => {}, setLoginInfo: () => {}, setOauthStatus,
+ beginAccountLogout: pools.beginAccountLogout,
+ notify: () => {}, fetchConfig: async () => {}, fetchOauth: async () => {}, fetchAccountSets: pools.fetchAccountSets,
+ fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {} });
+ return null;
+ }
+ try {
+ await render();
+ await act(async () => { void oauth.loginOAuth("anthropic2"); void oauth.cancelLoginOAuth("anthropic2"); });
+ expect(requests.filter(request => request.path === "/api/oauth/login")).toHaveLength(1);
+ expect(requests.filter(request => request.path === "/api/oauth/login/cancel")).toHaveLength(1);
+ await act(async () => { await oauth.logoutOAuth("anthropic2"); });
+ await act(async () => { await pools.removeAccount("anthropic2", accounts("anthropic2")[0]!); });
+ expect(requests.filter(request => request.path === "/api/oauth/logout")).toHaveLength(1);
+ expect(requests.filter(request => request.method === "DELETE")).toHaveLength(1);
+ } finally {
+ await act(async () => { login.resolve(Response.json({}, { status: 400 })); cancel.resolve(Response.json({ cancelled: false })); });
+ consent.mockRestore();
+ }
+});
+
+test.each(["success", "missing", "mismatch"] as const)("reauthentication keeps the requested account identity: %s", async outcome => {
+ let view!: LoginView;
+ respond = async request => {
+ if (request.path === "/api/oauth/login") return Response.json({ url: "https://example.test/login" });
+ if (request.path === "/api/oauth/status") return Response.json({ loggedIn: true, done: true,
+ accounts: outcome === "missing" ? [{ id: "other", active: true }]
+ : [{ id: "shared", active: true, needsReauth: outcome === "mismatch" }] });
+ return Response.json({});
+ };
+ await render( { view = next; }} />);
+ await act(async () => { await view.loginOAuth("anthropic2", true, "shared"); });
+ expect(requests.find(request => request.path === "/api/oauth/login")?.body)
+ .toMatchObject({ provider: "anthropic2", accountId: "shared", reauth: true, addAccount: true });
+ expect(view.notices).toHaveLength(1);
+ if (outcome === "missing") expect(view.notices[0]).toContain(en["prov.reauthAccountMissing"]);
+ if (outcome === "mismatch") expect(view.notices[0]).toContain(en["prov.reauthIdentityMismatch"]);
+ if (outcome === "success") expect(view.oauthStatus.anthropic2?.loggedIn).toBe(true);
+});
diff --git a/gui/tests/provider-account-pause-refresh.test.tsx b/gui/tests/provider-account-pause-refresh.test.tsx
index 28908ec35e3..7cc32cdfddb 100644
--- a/gui/tests/provider-account-pause-refresh.test.tsx
+++ b/gui/tests/provider-account-pause-refresh.test.tsx
@@ -227,6 +227,28 @@ test("reset confirmation after unmount clears its receipt without notifying the
expect(notices).toEqual([]);
});
+test("an affinity-reset retry reconciles an overtaken roster read when it settles", async () => {
+ let finishRead!: (response: Response) => void;
+ let finishRetry!: (response: Response) => void;
+ let reads = 0;
+ await act(async () => { rememberAffinityResetRetry("/pause-hook", "fixture", { kind: "reset", accountId: "b" }); });
+ respond = async (url, init) => {
+ if (init?.method === "PUT" && url.includes("affinity-reset")) return new Promise(resolve => { finishRetry = resolve; });
+ if (++reads === 1) return new Promise(resolve => { finishRead = resolve; });
+ return Response.json({ activeAccountId: "b", accounts: [row("a", false), row("b", true)] });
+ };
+ let read!: Promise;
+ let retry!: Promise;
+ await act(async () => { read = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); });
+ await act(async () => { retry = pools.retryAffinityReset("fixture"); });
+ await act(async () => { finishRead(Response.json({ activeAccountId: "a", accounts: [row("a", true)] })); expect(await read).toBe(false); });
+ expect(pools.accountLoadStates.fixture).toBe("loading");
+ await act(async () => { finishRetry(Response.json({ affinityResetConfirmed: true })); await retry; });
+ await act(async () => { await Promise.resolve(); });
+ expect(pools.accountLoadStates.fixture).toBe("ready");
+ expect(pools.accountSets.fixture.accounts.find(account => account.active)?.id).toBe("b");
+});
+
test("a successful inactive pause cannot replay a retained active selection into the paused account", async () => {
let writes = 0;
await act(async () => { rememberAffinityResetRetry("/pause-hook", "fixture", { kind: "active", accountId: "b" }); });
@@ -501,6 +523,24 @@ test("in-flight deletion owns its provider until the committed receipt is retain
expect(pools.pendingAffinityResets.fixture).toBeUndefined();
});
+test("a stalled unrelated OAuth status does not hold a completed deletion", async () => {
+ const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true);
+ let releaseStatus!: () => void;
+ let statusStarted!: () => void;
+ const started = new Promise(resolve => { statusStarted = resolve; });
+ fetchOauth = () => { statusStarted(); return new Promise(resolve => { releaseStatus = resolve; }); };
+ let switches = 0;
+ respond = async (_url, init) => init?.method === "DELETE" ? Response.json({ ok: true })
+ : init?.method === "PUT" ? (switches++, Response.json({ activeAccountId: "b" }))
+ : Response.json({ activeAccountId: "a", accounts: [row("a", true), row("b", false)] });
+ let deletion!: Promise;
+ try {
+ await act(async () => { deletion = pools.removeAccount("fixture", row("b", false)); await started; });
+ await act(async () => { void pools.switchAccount("fixture", row("b", false)); await Promise.resolve(); });
+ expect(switches).toBe(1);
+ } finally { await act(async () => { releaseStatus(); await deletion; }); consent.mockRestore(); }
+});
+
test("deletion rechecks ownership after the confirmation dialog settles", async () => {
let confirm!: (value: boolean) => void;
const consent = spyOn(dialogs, "confirmAction").mockImplementation(() => new Promise(resolve => { confirm = resolve; }));
@@ -524,6 +564,24 @@ test("deletion rechecks ownership after the confirmation dialog settles", async
}
});
+test.each([true, false])("failed deletion reconciles identity (roster succeeds: %s)", async succeeds => {
+ const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true);
+ let reads = 0;
+ respond = async (_url, init) => {
+ if (init?.method === "DELETE") return new Response(null, { status: 503 });
+ reads++;
+ return succeeds ? Response.json({ activeAccountId: "b", accounts: [row("a", false), row("b", true)] })
+ : new Response(null, { status: 503 });
+ };
+ try {
+ await act(async () => { await pools.removeAccount("fixture", row("b", false)); });
+ await act(async () => { await Promise.resolve(); });
+ expect(reads).toBe(1);
+ expect(pools.accountLoadStates.fixture).toBe(succeeds ? "ready" : "error");
+ if (succeeds) expect(pools.accountSets.fixture.accounts.find(account => account.active)?.id).toBe("b");
+ } finally { consent.mockRestore(); }
+});
+
test.each(["http", "transport"] as const)("failed deletion releases its mutation owner after %s failure", async failure => {
const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true);
let pauses = 0;
@@ -552,8 +610,10 @@ test("a rejected deletion without a committed receipt preserves the roster and r
};
try {
await act(async () => { await pools.removeAccount("fixture", row("a", true)); });
- expect(requests).toHaveLength(1);
+ expect(requests).toHaveLength(2);
expect(requests[0]).toStartWith("DELETE ");
+ expect(requests[1]).toStartWith("GET ");
+ expect(pools.accountLoadStates.fixture).toBe("error");
expect(pools.accountSets.fixture.accounts.map(account => account.id)).toEqual(["a", "b"]);
expect(pools.pendingAffinityResets.fixture).toEqual({ kind: "active", accountId: "b" });
expect(notices).toEqual([{ key: "prov.accountRemoveFail", ok: false }]);
diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx
index e173d1f1514..d52089b27a7 100644
--- a/gui/tests/provider-account-quota-loading.test.tsx
+++ b/gui/tests/provider-account-quota-loading.test.tsx
@@ -115,6 +115,19 @@ function Harness({ apiBase = "/quota-hook" }: { apiBase?: string }) {
useLayoutEffect(() => { pools = currentPools; }, [currentPools]);
return null;
}
+
+test("a roster read during logout cannot verify the current account", async () => {
+ respond = async () => Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, quotaMode: "unsupported" }] });
+ await act(async () => { await pools.fetchAccountSets(["fixture"]); });
+ expect(pools.accountLoadStates.fixture).toBe("ready");
+ let finish!: () => void;
+ await act(async () => { finish = pools.beginAccountLogout("fixture")!; });
+ expect(pools.accountLoadStates.fixture).toBe("loading");
+ await act(async () => { await pools.fetchAccountSets(["fixture"]); });
+ expect(pools.accountLoadStates.fixture).toBe("loading");
+ await act(async () => { finish(); await pools.fetchAccountSets(["fixture"]); });
+ expect(pools.accountLoadStates.fixture).toBe("ready");
+});
beforeEach(async () => {
previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])) as typeof previous;
win = new Window({ url: "http://localhost" });
@@ -213,6 +226,72 @@ afterEach(async () => {
await win.happyDOM.close();
});
+test("an OAuth selection refresh hides stale roster until successful reconciliation", async () => {
+ const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const }));
+ respond = async () => Response.json({ activeAccountId: "a", accounts: rows });
+ await act(async () => { expect(await pools.fetchAccountSets(["fixture"])).toBe(true); });
+ expect(pools.accountLoadStates.fixture).toBe("ready");
+ const pending = deferred();
+ respond = async () => pending.promise;
+ let refresh!: Promise;
+ await act(async () => { refresh = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); });
+ expect(pools.accountLoadStates.fixture).toBe("loading");
+ await act(async () => { pending.resolve(new Response(null, { status: 503 })); expect(await refresh).toBe(false); });
+ expect(pools.accountLoadStates.fixture).toBe("error");
+ respond = async () => Response.json({ activeAccountId: "b", accounts: rows });
+ await act(async () => { expect(await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" })).toBe(true); });
+ expect(pools.accountLoadStates.fixture).toBe("ready");
+ expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b");
+});
+
+test("a malformed or stale OAuth roster cannot publish a verified selection", async () => {
+ const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const }));
+ respond = async () => Response.json({ activeAccountId: "a", accounts: rows });
+ await act(async () => { await pools.fetchAccountSets(["fixture"]); });
+ respond = async () => Response.json({ accounts: null });
+ await act(async () => { expect(await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" })).toBe(false); });
+ expect(pools.accountLoadStates.fixture).toBe("error");
+ const old = deferred();
+ respond = async () => old.promise;
+ let pending!: Promise;
+ await act(async () => { pending = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); });
+ respond = async () => Response.json({ activeAccountId: "b", accounts: rows });
+ await act(async () => { expect(await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" })).toBe(true); });
+ await act(async () => { old.resolve(new Response(null, { status: 503 })); expect(await pending).toBe(false); });
+ expect(pools.accountLoadStates.fixture).toBe("ready");
+ expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b");
+});
+
+test("an aborted OAuth roster refresh leaves the retained selection unverified", async () => {
+ const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const }));
+ respond = async () => Response.json({ activeAccountId: "a", accounts: rows });
+ await act(async () => { await pools.fetchAccountSets(["fixture"]); });
+ const pending = deferred();
+ respond = async () => pending.promise;
+ const controller = new AbortController();
+ let refresh!: Promise;
+ await act(async () => { refresh = pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }, controller.signal); });
+ await act(async () => { controller.abort(); pending.resolve(Response.json({ activeAccountId: "a", accounts: rows })); expect(await refresh).toBe(false); });
+ expect(pools.accountLoadStates.fixture).toBe("error");
+});
+
+test("an OAuth quota roster cannot verify identity while account selection is pending", async () => {
+ const rows = ["a", "b"].map(id => ({ id, active: id === "a", quotaMode: "unsupported" as const }));
+ respond = async () => Response.json({ activeAccountId: "a", accounts: rows });
+ await act(async () => { await pools.fetchAccountSets(["fixture"]); });
+ const put = deferred();
+ respond = async (_url, _signal, init) => init?.method === "PUT" ? put.promise
+ : Response.json({ activeAccountId: "a", accounts: rows });
+ let switching!: Promise;
+ await act(async () => { switching = pools.switchAccount("fixture", rows[1]); });
+ await act(async () => { await pools.fetchAccountSets(["fixture"]); });
+ expect(pools.accountLoadStates.fixture).toBe("loading");
+ respond = async () => Response.json({ activeAccountId: "b", accounts: rows });
+ await act(async () => { put.resolve(Response.json({ activeAccountId: "b" })); await switching; });
+ expect(pools.accountLoadStates.fixture).toBe("ready");
+ expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b");
+});
+
for (const kind of ["oauth", "api-key"] as const) {
test(`${kind} push refresh overtakes a slow quota probe while late quota cannot change selection or resurrect rows`, async () => {
const quota = deferred();
diff --git a/gui/tests/provider-first-response-guidance.test.tsx b/gui/tests/provider-first-response-guidance.test.tsx
index 4fcf1867090..a3ad14df3e0 100644
--- a/gui/tests/provider-first-response-guidance.test.tsx
+++ b/gui/tests/provider-first-response-guidance.test.tsx
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
-import { act, type ReactNode } from "react";
+import { act, useLayoutEffect, type ReactNode } from "react";
import type { Root } from "react-dom/client";
import { clearClientResourceStoresForTests } from "../src/client-resource";
import ProviderModelsNotice from "../src/components/ProviderModelsNotice";
@@ -330,6 +330,101 @@ describe("first-response progression states on ProviderOverview", () => {
});
describe("ProviderDetails raw ID disambiguation and Providers page integration", () => {
+ test("logout hides the current account until the roster is reconciled", async () => {
+ let finishLogout!: (response: Response) => void;
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const path = new URL(String(input), "http://localhost").pathname;
+ if (path === "/api/oauth/logout") return new Promise(resolve => { finishLogout = resolve; });
+ if (path === "/api/config") return Response.json({ port: 0, defaultProvider: "anthropic", providers: {
+ anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" },
+ } });
+ if (path === "/api/providers") return Response.json([{ name: "anthropic", adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }]);
+ if (path === "/api/oauth/providers") return Response.json({ providers: ["anthropic"] });
+ if (path === "/api/oauth/status") return Response.json({ loggedIn: true });
+ if (path === "/api/oauth/accounts") return Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, alias: "Work" }] });
+ if (path === "/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} });
+ if (path === "/api/provider-presets") return Response.json({ providers: [] });
+ if (path === "/api/models") return Response.json([]);
+ if (path === "/api/provider-quotas") return Response.json({ reports: [] });
+ if (path === "/api/usage") return Response.json({ providers: [], models: [] });
+ if (path === "/api/codex-auth/accounts") return Response.json({ accounts: [] });
+ return Response.json({});
+ }) as typeof fetch;
+ await render();
+ await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); });
+ await act(async () => { host.querySelector('[role="option"]')!.click(); });
+ expect(host.textContent).toContain("Current accountWork");
+ await act(async () => { host.querySelector("#pws-tab-accounts")!.click(); });
+ const logout = [...host.querySelectorAll("button")].find(button => button.textContent === "Logout")!;
+ await act(async () => { logout.click(); });
+ await act(async () => { host.querySelector("#pws-tab-overview")!.click(); });
+ expect(host.textContent).not.toContain("Current accountWork");
+ expect(host.querySelector(".pws-auth-summary")?.textContent).toContain("Not verified");
+ await act(async () => { finishLogout(new Response(null, { status: 503 })); await Promise.resolve(); });
+ await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); });
+ expect(host.textContent).toContain("Current accountWork");
+ });
+ test("changing API targets does not unmount an unsaved Settings draft", async () => {
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const path = new URL(String(input), "http://localhost").pathname;
+ if (path.startsWith("/new/")) return new Promise(() => {});
+ if (path === "/old/api/config") return Response.json({ port: 0, defaultProvider: "my-provider", providers: {
+ "my-provider": { adapter: "openai-chat", baseUrl: "https://example.test/v1", hasApiKey: true },
+ } });
+ if (path === "/old/api/providers") return Response.json([{ name: "my-provider", adapter: "openai-chat", baseUrl: "https://example.test/v1", hasApiKey: true }]);
+ if (path === "/old/api/oauth/providers" || path === "/old/api/provider-presets") return Response.json({ providers: [] });
+ if (path === "/old/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} });
+ if (path === "/old/api/models") return Response.json([]);
+ if (path === "/old/api/provider-quotas") return Response.json({ reports: [] });
+ if (path === "/old/api/usage") return Response.json({ providers: [], models: [] });
+ if (path === "/old/api/codex-auth/accounts") return Response.json({ accounts: [] });
+ return Response.json({});
+ }) as typeof fetch;
+ await render();
+ await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); });
+ await act(async () => { host.querySelector('[role="option"]')!.click(); });
+ await act(async () => { host.querySelector("#pws-tab-settings")!.click(); });
+ const input = [...host.querySelectorAll("input")].find(element => element.value === "https://example.test/v1")!;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, "value")!.set!.call(input, "https://draft.example.test/v1");
+ input.dispatchEvent(new win.Event("input", { bubbles: true }));
+ });
+ expect(host.textContent).toContain("unsaved changes");
+ await render();
+ expect([...host.querySelectorAll("input")].some(element => element.value === "https://draft.example.test/v1")).toBe(true);
+ expect(host.textContent).toContain("unsaved changes");
+ });
+ test("switching API targets never paints the previous server's account", async () => {
+ let firstNewPaint = "";
+ function Inspect({ apiBase }: { apiBase: string }) {
+ useLayoutEffect(() => { if (apiBase === "/new") firstNewPaint = host.textContent ?? ""; }, [apiBase]);
+ return ;
+ }
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const path = new URL(String(input), "http://localhost").pathname;
+ if (path.startsWith("/new/")) return new Promise(() => {});
+ if (path === "/old/api/config") return Response.json({ port: 0, defaultProvider: "anthropic", providers: {
+ anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" },
+ } });
+ if (path === "/old/api/providers") return Response.json([{ name: "anthropic", adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }]);
+ if (path === "/old/api/oauth/providers") return Response.json({ providers: ["anthropic"] });
+ if (path === "/old/api/oauth/status") return Response.json({ loggedIn: true });
+ if (path === "/old/api/oauth/accounts") return Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, alias: "Old Server Account" }] });
+ if (path === "/old/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} });
+ if (path === "/old/api/models") return Response.json([]);
+ if (path === "/old/api/provider-quotas") return Response.json({ reports: [] });
+ if (path === "/old/api/usage") return Response.json({ providers: [], models: [] });
+ if (path === "/old/api/codex-auth/accounts") return Response.json({ accounts: [] });
+ return Response.json({});
+ }) as typeof fetch;
+ await render();
+ await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); });
+ await act(async () => { host.querySelector('[role="option"]')!.click(); });
+ expect(host.textContent).toContain("Old Server Account");
+ await render();
+ expect(firstNewPaint).not.toContain("Old Server Account");
+ expect(host.textContent).not.toContain("Old Server Account");
+ });
test("ProviderDetails header shows both display name and raw config ID when they differ", async () => {
await render(
{
+ let releaseOld!: (response: Response) => void;
+ let late = false;
+ let oldStarted!: () => void;
+ const oldRequested = new Promise(resolve => { oldStarted = resolve; });
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url = new URL(String(input), "http://localhost");
+ const target = url.pathname.startsWith("/a/") ? "a" : "b";
+ const path = url.pathname.replace(/^\/[ab]/, "");
+ if (path === "/api/config") return Response.json({ port: 0, defaultProvider: "kimi", providers: {
+ kimi: { adapter: "kimi", baseUrl: "https://api.kimi.com", authMode: "oauth" },
+ } });
+ if (path === "/api/providers") return Response.json([{ name: "kimi", adapter: "kimi", authMode: "oauth" }]);
+ if (path === "/api/oauth/providers") return target === "b" && failure === "discovery" ? new Response(null, { status: 503 }) : Response.json({ providers: ["kimi"] });
+ if (path === "/api/oauth/status") {
+ if (target === "b") return new Response(null, { status: 503 });
+ if (late) { oldStarted(); return new Promise(resolve => { releaseOld = resolve; }); }
+ return Response.json({ loggedIn: true, email: "identity-a@example.test" });
+ }
+ if (path === "/api/oauth/accounts") return new Response(null, { status: 503 });
+ if (path === "/api/selected-models") return Response.json({ selected: {}, available: {}, liveModelCounts: {} });
+ if (path === "/api/provider-presets") return Response.json({ providers: [] });
+ if (path === "/api/models") return Response.json([]);
+ if (path === "/api/provider-quotas") return Response.json({ reports: [] });
+ if (path === "/api/usage") return Response.json({ providers: [], models: [] });
+ if (path === "/api/codex-auth/accounts") return Response.json({ accounts: [] });
+ return Response.json({});
+ }) as typeof fetch;
+ const commits: string[] = [];
+ function Observe({ target }: { target: string }) {
+ useLayoutEffect(() => { commits.push(`${target}:${host.textContent}`); });
+ return null;
+ }
+ const page = (target: string) => <>>;
+ await render(page("a"));
+ await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); });
+ await act(async () => { host.querySelector('[role="option"]')!.click(); });
+ expect(host.textContent).toContain("identity-a@example.test");
+ late = true;
+ // Trigger a fresh OAuth read through the existing refresh event by returning to A after B.
+ await render(page("b"));
+ expect(commits.find(value => value.startsWith("b:"))).not.toContain("identity-a@example.test");
+ await act(async () => { await new Promise(resolve => win.setTimeout(resolve, 40)); });
+ expect(host.textContent).not.toContain("identity-a@example.test");
+ await render(page("a"));
+ await act(async () => { await oldRequested; });
+ const releaseFirstOld = releaseOld;
+ await render(page("b"));
+ await render(page("a"));
+ await act(async () => { releaseFirstOld(Response.json({ loggedIn: true, email: "stale-a@example.test" })); await Promise.resolve(); });
+ expect(host.textContent).not.toContain("stale-a@example.test");
+});
diff --git a/gui/tests/provider-models-notice.test.tsx b/gui/tests/provider-models-notice.test.tsx
index b69007ab096..90094ddbb2b 100644
--- a/gui/tests/provider-models-notice.test.tsx
+++ b/gui/tests/provider-models-notice.test.tsx
@@ -6,7 +6,7 @@ import ProviderModelsNotice, { type ProviderModelsNoticeProps } from "../src/com
import { LanguageProvider } from "../src/i18n/provider";
import { useProviderModelsNotice } from "../src/pages/use-provider-models-notice";
import { useProvidersFetch } from "../src/pages/use-providers-fetch";
-import type { ProvidersConfig } from "../src/pages/providers-shared";
+import type { OAuthStatus, ProvidersConfig } from "../src/pages/providers-shared";
const keys = ["window", "document", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const;
let saved: Record;
@@ -41,6 +41,145 @@ function button(label: string): HTMLButtonElement {
return found;
}
+test("a failed newer OAuth discovery does not discard an earlier success", async () => {
+ let loader!: ReturnType;
+ let providers: string[] = [];
+ let finish!: (response: Response) => void;
+ let reads = 0;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ if (String(input).includes("/api/oauth/providers")) {
+ if (++reads === 1) return new Promise(resolve => { finish = resolve; });
+ return new Response("unavailable", { status: 503 });
+ }
+ return Response.json({ loggedIn: true });
+ } });
+ function Harness() {
+ const [state, setProviders] = useState([]);
+ providers = state;
+ loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: setProviders,
+ setOauthStatus: () => {}, notify: () => {}, invalidateProviderQuotas: () => {} });
+ return null;
+ }
+ await render();
+ let first!: Promise;
+ await act(async () => { first = loader.fetchOauth(); await Promise.resolve(); });
+ await act(async () => { await loader.fetchOauth(); });
+ await act(async () => { finish(Response.json({ providers: ["anthropic"] })); await first; });
+ expect(providers).toEqual(["anthropic"]);
+});
+
+test("a failed newer OAuth status does not replace an earlier success", async () => {
+ let loader!: ReturnType;
+ let statuses: Record = {};
+ let finish!: (response: Response) => void;
+ let started!: () => void;
+ const requested = new Promise(resolve => { started = resolve; });
+ let reads = 0;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ if (String(input).includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] });
+ if (++reads === 1) { started(); return new Promise(resolve => { finish = resolve; }); }
+ return new Response("unavailable", { status: 503 });
+ } });
+ function Harness() {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state;
+ loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {},
+ setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} });
+ return null;
+ }
+ await render();
+ let first!: Promise;
+ await act(async () => { first = loader.fetchOauth(); await requested; });
+ await act(async () => { await loader.fetchOauth(); });
+ await act(async () => { finish(Response.json({ loggedIn: true })); await first; });
+ expect(statuses.anthropic).toEqual({ loggedIn: true });
+});
+
+test("an older OAuth refresh cannot overwrite a newer status", async () => {
+ let loader!: ReturnType;
+ let statuses: Record = {};
+ let finish!: (response: Response) => void;
+ let started!: () => void;
+ const requested = new Promise(resolve => { started = resolve; });
+ let reads = 0;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ if (String(input).includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] });
+ if (++reads === 1) { started(); return new Promise(resolve => { finish = resolve; }); }
+ return Response.json({ loggedIn: true });
+ } });
+ function Harness() {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state;
+ loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {},
+ setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} });
+ return null;
+ }
+ await render();
+ let old!: Promise;
+ await act(async () => { old = loader.fetchOauth(); await requested; });
+ await act(async () => { await loader.fetchOauth(); });
+ await act(async () => { finish(Response.json({ loggedIn: false })); await old; });
+ expect(statuses.anthropic).toEqual({ loggedIn: true });
+});
+
+test("a login update cannot be overwritten by an older OAuth status", async () => {
+ let loader!: ReturnType;
+ let statuses: Record = {};
+ let publish!: (next: Record) => void;
+ let finish!: (response: Response) => void;
+ let started!: () => void;
+ const requested = new Promise(resolve => { started = resolve; });
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ if (String(input).includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] });
+ started(); return new Promise(resolve => { finish = resolve; });
+ } });
+ function Harness() {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state; publish = setOauthStatus;
+ loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {},
+ setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} });
+ return null;
+ }
+ await render();
+ let old!: Promise;
+ await act(async () => { old = loader.fetchOauth(); await requested; });
+ await act(async () => { loader.invalidateOauthStatusReads("anthropic"); publish({ anthropic: { loggedIn: true } }); });
+ await act(async () => { finish(Response.json({ loggedIn: false })); await old; });
+ expect(statuses.anthropic).toEqual({ loggedIn: true });
+});
+
+test("a newer pending OAuth refresh does not starve another provider's earlier result", async () => {
+ let loader!: ReturnType;
+ let statuses: Record = {};
+ const releases = new Map void>>();
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/api/oauth/providers")) return Response.json({ providers: ["anthropic", "cursor"] });
+ const name = new URL(url, "http://localhost").searchParams.get("provider")!;
+ return new Promise(resolve => { const list = releases.get(name) ?? []; list.push(resolve); releases.set(name, list); });
+ } });
+ function Harness() {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state;
+ loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {},
+ setOauthStatus, notify: () => {}, invalidateProviderQuotas: () => {} });
+ return null;
+ }
+ await render();
+ let first!: Promise, second!: Promise;
+ await act(async () => { first = loader.fetchOauth(); await Promise.resolve(); await Promise.resolve(); });
+ await act(async () => { second = loader.fetchOauth(); await Promise.resolve(); await Promise.resolve(); });
+ expect(releases.get("cursor")?.length).toBe(2);
+ await act(async () => { releases.get("cursor")![0]!(Response.json({ loggedIn: true })); });
+ expect(statuses.cursor).toEqual({ loggedIn: true });
+ await act(async () => {
+ releases.get("anthropic")![0]!(Response.json({ loggedIn: false }));
+ releases.get("anthropic")![1]!(Response.json({ loggedIn: true }));
+ releases.get("cursor")![1]!(Response.json({ loggedIn: true }));
+ await Promise.all([first, second]);
+ });
+});
+
test("all-OFF notice has keyboard navigation, explicit actions and focus restoration", async () => {
const trigger = win.document.createElement("button");
win.document.body.appendChild(trigger);
diff --git a/gui/tests/provider-oauth-status-races.test.tsx b/gui/tests/provider-oauth-status-races.test.tsx
new file mode 100644
index 00000000000..51bf0958203
--- /dev/null
+++ b/gui/tests/provider-oauth-status-races.test.tsx
@@ -0,0 +1,267 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, useRef, useState, type ReactNode } from "react";
+import type { Root } from "react-dom/client";
+import { useProvidersFetch } from "../src/pages/use-providers-fetch";
+import { useProvidersOAuth } from "../src/pages/use-providers-oauth";
+import type { OAuthStatus } from "../src/pages/providers-shared";
+
+const keys = ["window", "document", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let saved: Record;
+let win: Window;
+let root: Root | undefined;
+let host: HTMLElement;
+beforeEach(() => {
+ saved = Object.fromEntries(keys.map(key => [key, Reflect.get(globalThis, key)]));
+ win = new Window({ url: "http://localhost/" });
+ for (const key of ["window", "document", "navigator", "localStorage", "sessionStorage"] as const)
+ Object.defineProperty(globalThis, key, { configurable: true, value: key === "window" ? win : win[key] });
+ Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
+ host = win.document.createElement("div") as unknown as HTMLElement;
+ win.document.body.appendChild(host as never);
+});
+afterEach(async () => {
+ if (root) await act(async () => { root?.unmount(); });
+ root = undefined;
+ await win.happyDOM.close();
+ for (const key of keys) Object.defineProperty(globalThis, key, { configurable: true, value: saved[key] });
+});
+async function render(node: ReactNode) {
+ const { createRoot } = await import("react-dom/client");
+ await act(async () => { root ??= createRoot(host); root.render(node); });
+}
+
+test.each(["failed refresh", "late read", "read during logout"] as const)("logout %s fences old status, preserves other providers and recovers a remaining account", async mode => {
+ let loader!: ReturnType;
+ let actions!: ReturnType;
+ let statuses: Record = {};
+ let releaseLogout!: (r: Response) => void;
+ let releaseOld!: (r: Response) => void;
+ let started!: () => void;
+ const oldStarted = new Promise(resolve => { started = resolve; });
+ let phase: "initial" | "during" | "failed" | "recovered" = "initial";
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL) => {
+ const url = new URL(String(input), "http://localhost");
+ if (url.pathname === "/api/oauth/logout") return new Promise(resolve => { releaseLogout = resolve; });
+ if (url.pathname === "/api/oauth/providers") return Response.json({ providers: ["anthropic", "kimi"] });
+ if (url.searchParams.get("provider") === "kimi") return Response.json({ loggedIn: true, email: "kimi@example.test" });
+ if (phase === "during") { started(); return new Promise(resolve => { releaseOld = resolve; }); }
+ if (phase === "failed") return new Response(null, { status: 503 });
+ return Response.json({ loggedIn: true, email: phase === "initial" ? "first@example.test" : "remaining@example.test" });
+ } });
+ function Harness() {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state;
+ const aliveRef = useRef(true);
+ loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus,
+ notify: () => {}, invalidateProviderQuotas: () => {} });
+ actions = useProvidersOAuth({ apiBase: "", t: key => key, aliveRef, accountSets: {}, setAccountSets: () => {},
+ setBusy: () => {}, setStatus: () => {}, setLoginInfo: () => {}, setOauthStatus,
+ invalidateOauthStatusReads: loader.invalidateOauthStatusReads, beginAccountLogout: () => () => {}, notify: () => {},
+ fetchConfig: async () => {}, fetchOauth: loader.fetchOauth, fetchAccountSets: async () => {},
+ fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {} });
+ return null;
+ }
+ await render();
+ await act(async () => { await loader.fetchOauth(); });
+ expect(statuses.anthropic?.email).toBe("first@example.test");
+ let logout!: Promise;
+ await act(async () => { logout = actions.logoutOAuth("anthropic"); });
+ if (mode !== "failed refresh") expect(statuses.anthropic).toBeUndefined();
+ expect(statuses.kimi?.email).toBe("kimi@example.test");
+ phase = "during";
+ let old!: Promise;
+ await act(async () => { old = loader.fetchOauth(); await oldStarted; });
+ if (mode === "read during logout") {
+ expect(loader.invalidateOauthStatusReads("anthropic")).toBe(false);
+ await act(async () => { releaseOld(Response.json({ loggedIn: true, email: "first@example.test" })); await old; });
+ expect(statuses.anthropic).toBeUndefined();
+ }
+ phase = "failed";
+ await act(async () => { releaseLogout(new Response(null, { status: 200 })); await logout; });
+ if (mode === "failed refresh") expect(statuses.anthropic).toBeUndefined();
+ if (mode !== "read during logout") await act(async () => { releaseOld(Response.json({ loggedIn: true, email: "first@example.test" })); await old; });
+ expect(statuses.anthropic).toBeUndefined();
+ expect(statuses.kimi?.email).toBe("kimi@example.test");
+ phase = "recovered";
+ await act(async () => { await loader.fetchOauth(); });
+ expect(statuses.anthropic).toEqual({ loggedIn: true, email: "remaining@example.test" });
+});
+
+test.each(["success", "partial", "failure", "network"] as const)("account deletion %s fences status and recovers after settlement", async outcome => {
+ const dialogs = await import("../src/action-dialogs");
+ const { spyOn } = await import("bun:test");
+ const { useProviderAccountPools } = await import("../src/hooks/useProviderAccountPools");
+ const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true);
+ let loader!: ReturnType;
+ let pools!: ReturnType;
+ let statuses: Record = {};
+ let releaseDelete!: (r: Response) => void;
+ let rejectDelete!: (e: Error) => void;
+ let releaseOld!: (r: Response) => void;
+ let releaseDuring!: (r: Response) => void;
+ let started!: () => void;
+ let duringStarted!: () => void;
+ const oldStarted = new Promise(resolve => { started = resolve; });
+ const duringReady = new Promise(resolve => { duringStarted = resolve; });
+ let held = 0;
+ let phase: "initial" | "during" | "failed" | "recovered" = "initial";
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(String(input), "http://localhost");
+ if (init?.method === "DELETE") return new Promise((resolve, reject) => { releaseDelete = resolve; rejectDelete = reject; });
+ if (url.pathname === "/api/oauth/providers") return Response.json({ providers: ["anthropic", "kimi"] });
+ if (url.pathname === "/api/oauth/accounts") return Response.json({ activeAccountId: null, accounts: [] });
+ if (url.searchParams.get("provider") === "kimi") return Response.json({ loggedIn: true, email: "kimi@example.test" });
+ if (phase === "during") return new Promise(resolve => {
+ if (held++ === 0) { releaseOld = resolve; started(); }
+ else { releaseDuring = resolve; duringStarted(); }
+ });
+ if (phase === "failed") return new Response(null, { status: 503 });
+ return Response.json({ loggedIn: true, email: phase === "initial" ? "first@example.test" : "remaining@example.test" });
+ } });
+ function Harness() {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state;
+ const aliveRef = useRef(true);
+ loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus,
+ notify: () => {}, invalidateProviderQuotas: () => {} });
+ pools = useProviderAccountPools({ apiBase: "", config: null, aliveRef, t: key => key, oauthStatus: state, notify: () => {},
+ fetchConfig: async () => {}, fetchOauth: loader.fetchOauth, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false,
+ invalidateOauthStatusReads: loader.invalidateOauthStatusReads, setOauthStatus });
+ return null;
+ }
+ try {
+ await render();
+ await act(async () => { await loader.fetchOauth(); });
+ let old!: Promise;
+ phase = "during";
+ await act(async () => { old = loader.fetchOauth(); await oldStarted; });
+ let deletion!: Promise;
+ await act(async () => { deletion = pools.removeAccount("anthropic", { id: "a", active: true }); await Promise.resolve(); });
+ expect(statuses.anthropic).toBeUndefined();
+ expect(statuses.kimi?.email).toBe("kimi@example.test");
+ let during!: Promise;
+ await act(async () => { during = loader.fetchOauth(); await duringReady; });
+ if (outcome === "success") {
+ await act(async () => { releaseDuring(Response.json({ loggedIn: true, email: "first@example.test" })); await during; });
+ expect(statuses.anthropic).toBeUndefined();
+ }
+ phase = "failed";
+ await act(async () => {
+ if (outcome === "network") rejectDelete(new Error("offline"));
+ else releaseDelete(outcome === "success" ? Response.json({}) : outcome === "partial"
+ ? Response.json({ code: "affinity_reset_not_persisted", retryable: true, deletionCommitted: true }, { status: 503 })
+ : new Response(null, { status: 500 }));
+ await deletion;
+ releaseOld(Response.json({ loggedIn: true, email: "first@example.test" })); await old;
+ if (outcome !== "success") { releaseDuring(Response.json({ loggedIn: true, email: "first@example.test" })); await during; }
+ });
+ expect(statuses.anthropic).toBeUndefined();
+ phase = "recovered";
+ await act(async () => { await loader.fetchOauth(); });
+ expect(statuses.anthropic?.email).toBe("remaining@example.test");
+ expect(statuses.kimi?.email).toBe("kimi@example.test");
+ } finally { consent.mockRestore(); }
+});
+
+test("a deletion settling after a target change cannot clear or refresh the new target", async () => {
+ const dialogs = await import("../src/action-dialogs");
+ const { spyOn } = await import("bun:test");
+ const { useProviderAccountPools } = await import("../src/hooks/useProviderAccountPools");
+ const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true);
+ let loader!: ReturnType;
+ let pools!: ReturnType;
+ let statuses: Record = {};
+ let release!: (response: Response) => void;
+ const requests: string[] = [];
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(String(input), "http://localhost");
+ requests.push(url.pathname);
+ if (init?.method === "DELETE") return new Promise(resolve => { release = resolve; });
+ if (url.pathname.endsWith("/api/oauth/providers")) return Response.json({ providers: ["anthropic"] });
+ if (url.pathname.endsWith("/api/oauth/status")) return Response.json({ loggedIn: true, email: url.pathname.startsWith("/new") ? "new@example.test" : "old@example.test" });
+ return Response.json({ activeAccountId: null, accounts: [] });
+ } });
+ function Harness({ base }: { base: string }) {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state;
+ const aliveRef = useRef(true);
+ loader = useProvidersFetch({ apiBase: base, t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus,
+ notify: () => {}, invalidateProviderQuotas: () => {} });
+ pools = useProviderAccountPools({ apiBase: base, config: null, aliveRef, t: key => key, oauthStatus: state, notify: () => {},
+ fetchConfig: async () => {}, fetchOauth: loader.fetchOauth, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false,
+ invalidateOauthStatusReads: loader.invalidateOauthStatusReads, setOauthStatus });
+ return null;
+ }
+ try {
+ await render();
+ await act(async () => { await loader.fetchOauth(); });
+ let deletion!: Promise;
+ await act(async () => { deletion = pools.removeAccount("anthropic", { id: "a", active: true }); await Promise.resolve(); });
+ await render();
+ await act(async () => { await loader.fetchOauth(); });
+ expect(statuses.anthropic?.email).toBe("new@example.test");
+ const oldReads = requests.filter(path => path === "/old/api/oauth/providers").length;
+ await act(async () => { release(Response.json({})); await deletion; });
+ expect(statuses.anthropic?.email).toBe("new@example.test");
+ expect(requests.filter(path => path === "/old/api/oauth/providers")).toHaveLength(oldReads);
+ } finally { consent.mockRestore(); }
+});
+
+test("a login status read predating account deletion cannot restore the deleted identity", async () => {
+ const { spyOn } = await import("bun:test");
+ const dialogs = await import("../src/action-dialogs");
+ const { useProviderAccountPools } = await import("../src/hooks/useProviderAccountPools");
+ const consent = spyOn(dialogs, "confirmAction").mockResolvedValue(true);
+ const oldStatus = Promise.withResolvers();
+ const statusStarted = Promise.withResolvers();
+ const freshStatus = Promise.withResolvers();
+ const freshStarted = Promise.withResolvers();
+ let statusReads = 0;
+ let statuses: Record = {};
+ const events: string[] = [];
+ let actions!: ReturnType;
+ let pools!: ReturnType;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = new URL(String(input), "http://localhost");
+ events.push(`${init?.method ?? "GET"} ${url.pathname}`);
+ if (url.pathname === "/api/oauth/login") return Response.json({ url: "https://example.test/login" });
+ if (url.pathname === "/api/oauth/status") {
+ if (statusReads++ === 0) { statusStarted.resolve(); return oldStatus.promise; }
+ freshStarted.resolve(); return freshStatus.promise;
+ }
+ if (url.pathname === "/api/oauth/providers") return Response.json({ providers: [] });
+ if (init?.method === "DELETE") return Response.json({});
+ return Response.json({ activeAccountId: null, accounts: [] });
+ } });
+ function Harness() {
+ const [state, setOauthStatus] = useState>({});
+ statuses = state;
+ const aliveRef = useRef(true);
+ const [, setBusy] = useState(null);
+ const [, setLoginInfo] = useState[0]["setLoginInfo"] extends React.Dispatch> ? S : never>(null);
+ const loader = useProvidersFetch({ apiBase: "", t: key => key, setConfig: () => {}, setOauthProviders: () => {}, setOauthStatus,
+ notify: () => {}, invalidateProviderQuotas: () => {} });
+ pools = useProviderAccountPools({ apiBase: "", config: null, aliveRef, t: key => key, oauthStatus: state, notify: () => {},
+ fetchConfig: async () => {}, fetchOauth: async () => {}, fetchProviderQuotas: async () => {}, codexActiveNeedsReauth: false,
+ invalidateOauthStatusReads: loader.invalidateOauthStatusReads, setOauthStatus });
+ actions = useProvidersOAuth({ apiBase: "", t: key => key, aliveRef, accountSets: pools.accountSets, setAccountSets: pools.setAccountSets,
+ setBusy, setStatus: () => {}, setLoginInfo, setOauthStatus, invalidateOauthStatusReads: loader.invalidateOauthStatusReads, captureOauthStatusRead: loader.captureOauthStatusRead,
+ notify: () => {}, fetchConfig: async () => {}, fetchOauth: async () => {}, fetchAccountSets: pools.fetchAccountSets,
+ fetchProviderQuotas: async () => {}, bumpModelsRefresh: () => {} });
+ return null;
+ }
+ try {
+ await render();
+ let login!: Promise;
+ await act(async () => { login = actions.loginOAuth("anthropic", true); });
+ await act(async () => { await statusStarted.promise; });
+ await act(async () => { await pools.removeAccount("anthropic", { id: "old", email: "old@example.test", active: true }); });
+ await act(async () => { oldStatus.resolve(Response.json({ loggedIn: true, done: true, email: "old@example.test", accounts: [{ id: "old", active: true }] })); await freshStarted.promise; });
+ expect(events).toContain("DELETE /api/oauth/accounts");
+ expect(statuses.anthropic?.email).not.toBe("old@example.test");
+ expect(pools.accountSets.anthropic?.accounts.some(account => account.id === "old") ?? false).toBe(false);
+ await act(async () => { freshStatus.resolve(Response.json({ loggedIn: true, done: true, email: "new@example.test", accounts: [{ id: "new", active: true }] })); await login; });
+ expect(statuses.anthropic?.email).toBe("new@example.test");
+ } finally { consent.mockRestore(); oldStatus.resolve(Response.json({})); freshStatus.resolve(Response.json({})); }
+});
diff --git a/structure/account-auto-pause.md b/structure/account-auto-pause.md
index 30b0ffcdd0d..3c260bf6d2b 100644
--- a/structure/account-auto-pause.md
+++ b/structure/account-auto-pause.md
@@ -110,6 +110,11 @@ timestamp with the app locale. Disabled key rows offer Resume through their exis
`gui/src/hooks/useProviderAccountPools.ts` resumes before activation, then refreshes the
roster under its existing selection-mutation fence. No upstream error body is displayed.
+The account-pool hook coordinates logout with pending account selection and deletion per provider.
+The account panel exposes login cancellation once the provider supplies a login hint.
+When the roster and OAuth status cannot be verified, the account panel keeps logout available
+without presenting an unverified account as signed in.
+
`src/lib/account-auto-pause-response.ts` registers process-local pause/refusal provenance with the pacing owner and retains it when
adapter error normalization or a leased pacing-body rewrap replaces a response, without exposing metadata in HTTP headers.