From 38f33f025f822d37965c5e69b61d98ca3fd34e3f Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 06:41:40 +0200 Subject: [PATCH 1/5] fix(gui): keep Cursor OAuth accounts visible and toast login status Stop the Providers login banner from shifting layout, seed/refresh the account list after OAuth, and open Accounts so multi-account Cursor logins stay visible. --- gui/src/components/AddProviderModal.tsx | 6 ++- .../provider-catalog/ProviderCatalog.tsx | 28 +++++++++++- .../provider-workspace/ProviderDetails.tsx | 13 +++++- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Providers.tsx | 44 ++++++++++++++++--- gui/src/pages/providers-page-modals.tsx | 5 ++- gui/src/pages/use-providers-oauth.ts | 42 ++++++++++++++---- gui/src/styles.css | 39 ++++++++++++++++ gui/src/ui.tsx | 35 +++++++++++++++ src/oauth/cursor.ts | 32 +++++++++++--- src/oauth/index.ts | 2 +- tests/cursor-oauth.test.ts | 14 ++++++ tests/oauth-store-multi.test.ts | 12 +++++ 18 files changed, 251 insertions(+), 27 deletions(-) diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 333971bbb2..8d06bd0020 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -28,7 +28,7 @@ type Preset = CatalogPreset; export default function AddProviderModal({ apiBase, existingNames, onClose, onAdded, initialTier, initialCustom = false, - accountRows, accountStatus, accountBusy, onAccountLogin, onAccountCancelLogin, onAccountLogout, onOpen, + accountRows, accountStatus, accountBusy, onAccountLogin, onAccountCancelLogin, onAccountLogout, onAccountManage, onOpen, }: { apiBase: string; existingNames: string[]; @@ -39,9 +39,10 @@ export default function AddProviderModal({ accountRows?: AccountLoginRow[]; accountStatus?: Record; accountBusy?: string | null; - onAccountLogin?: (provider: string) => void; + onAccountLogin?: (provider: string, addAccount?: boolean) => void; onAccountCancelLogin?: (provider: string) => void; onAccountLogout?: (provider: string) => void; + onAccountManage?: (provider: string) => void; onOpen?: () => void; }) { const t = useT(); @@ -248,6 +249,7 @@ export default function AddProviderModal({ onLogin={onAccountLogin} onCancelLogin={onAccountCancelLogin} onLogout={onAccountLogout} + onManage={onAccountManage} /> ) : form && ( preset.auth === "oauth" && form.authMode === "oauth" ? ( diff --git a/gui/src/components/provider-catalog/ProviderCatalog.tsx b/gui/src/components/provider-catalog/ProviderCatalog.tsx index dc093bf6a1..5b60a0d9e1 100644 --- a/gui/src/components/provider-catalog/ProviderCatalog.tsx +++ b/gui/src/components/provider-catalog/ProviderCatalog.tsx @@ -41,6 +41,7 @@ export default function ProviderCatalog({ onLogin, onCancelLogin, onLogout, + onManage, }: { presets: CatalogPreset[]; usageRank?: Record; @@ -52,9 +53,11 @@ export default function ProviderCatalog({ accountRows?: AccountLoginRow[]; accountStatus?: Record; busyProvider?: string | null; - onLogin?: (provider: string) => void; + onLogin?: (provider: string, addAccount?: boolean) => void; onCancelLogin?: (provider: string) => void; onLogout?: (provider: string) => void; + /** Jump to the provider's Accounts surface in the workspace. */ + onManage?: (provider: string) => void; }) { const t = useT(); const [tier, setTier] = useState(initialTier); @@ -172,7 +175,28 @@ export default function ProviderCatalog({ )} ) : loggedIn ? ( - onLogout && + <> + {onManage && ( + + )} + {onLogin && ( + + )} + {onLogout && ( + + )} + ) : busy ? ( onCancelLogin && ) : ( diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index e825dcee68..de9295223b 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -2,7 +2,7 @@ * ProviderDetails — the detail header + tab shell (WP090+091). Owns tab state * and composes the Overview/Models/Usage/Settings panels. */ -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { formatProviderDisplayName } from "../../provider-icons"; @@ -41,6 +41,7 @@ export default function ProviderDetails({ oauth, accounts, accountLoadState, + accountsFocusToken = 0, switchingAccountId, keys, busyProvider, @@ -70,6 +71,8 @@ export default function ProviderDetails({ oauth?: { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; accounts?: OAuthAccountRow[]; accountLoadState?: AccountLoadState; + /** When this token increases, switch to the Accounts tab (post-OAuth reveal). */ + accountsFocusToken?: number; switchingAccountId?: string | null; keys?: ApiKeyRow[]; busyProvider?: string | null; @@ -89,6 +92,7 @@ export default function ProviderDetails({ const [pendingLeave, setPendingLeave] = useState(null); const [leaveSaving, setLeaveSaving] = useState(false); const settingsSaveRef = useRef<(() => Promise) | null>(null); + const lastAccountsFocusTokenRef = useRef(0); const registerSettingsSave = useCallback((save: (() => Promise) | null) => { settingsSaveRef.current = save; }, []); @@ -112,6 +116,13 @@ export default function ProviderDetails({ setTab(next); }, [tab, settingsDirty]); + useEffect(() => { + if (!accountsFocusToken || accountsFocusToken === lastAccountsFocusTokenRef.current) return; + lastAccountsFocusTokenRef.current = accountsFocusToken; + if (!authSurface) return; + setTab("accounts"); + }, [accountsFocusToken, authSurface]); + const requestDeselect = useCallback(() => { if (settingsDirty && tab === "settings") { setPendingLeave("deselect"); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a21cdbedc2..33baf0add7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -280,6 +280,7 @@ export const de: Record = { "prov.loginCancelled": "{provider}-Login abgebrochen", "prov.loginTimeout": "{provider}-Login abgelaufen — Browser geschlossen oder nicht beendet. Erneut versuchen.", "prov.loginOk": "Bei {provider} angemeldet. Führe {cmd} aus (oder es gilt live), um seine Modelle aufzulisten.", + "prov.loginSameAccount": "Immer noch dasselbe {provider}-Konto — wechsle im Browser das Konto und versuche „Konto hinzufügen“ erneut.", "oauthTos.highTitle": "{provider}: Risiko bei Abo-OAuth", "oauthTos.elevatedTitle": "{provider}: inoffizielle OAuth-Brücke", "oauthTos.anthropicBody": "Die direkte Wiederverwendung von Claude-Abo-OAuth-Tokens über einen Drittanbieter-Proxy wie OpenCodex ist keine von Anthropic unterstützte Integration und kann zu Zugriffsbeschränkungen führen. Unterstützte Agent-SDK-Integrationen, die Claude-Abos verwenden, sind davon getrennt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1cb0a0e5e5..82210e3913 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -292,6 +292,7 @@ export const en = { "prov.loginCancelled": "{provider} login cancelled", "prov.loginTimeout": "{provider} login timed out — browser closed or never finished. Try again.", "prov.loginOk": "Logged in to {provider}. Run {cmd} (or it applies live) to list its models.", + "prov.loginSameAccount": "Still the same {provider} account — switch accounts in the browser, then try Add account again.", "oauthTos.highTitle": "{provider}: subscription OAuth risk", "oauthTos.elevatedTitle": "{provider}: unofficial OAuth bridge", "oauthTos.anthropicBody": "Directly reusing Claude subscription OAuth tokens through a third-party proxy such as OpenCodex is not a supported Anthropic integration and may lead to access restrictions. Supported Agent SDK integrations that use Claude subscriptions are separate.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0feefa7d08..ebf66f9ec7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -286,6 +286,7 @@ export const ja: Record = { "prov.loginCancelled": "{provider} ログインはキャンセルされました", "prov.loginTimeout": "{provider} ログインがタイムアウトしました — ブラウザが閉じたか完了しませんでした。もう一度お試しください。", "prov.loginOk": "{provider} にログインしました。{cmd} を実行(またはライブで適用)してモデルを一覧表示します。", + "prov.loginSameAccount": "同じ {provider} アカウントのままです。ブラウザでアカウントを切り替えてから、もう一度アカウント追加を試してください。", "oauthTos.highTitle": "{provider}: サブスクリプション OAuth リスク", "oauthTos.elevatedTitle": "{provider}: 非公式 OAuth ブリッジ", "oauthTos.anthropicBody": "OpenCodex のような第三者プロキシ経由で Claude サブスクリプションの OAuth トークンを直接再利用することは、Anthropic がサポートする統合ではなく、アクセス制限につながる可能性があります。Claude サブスクリプションを使用するサポートされた Agent SDK 統合は別物です。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8e0931da8f..2d33fc07d7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -286,6 +286,7 @@ export const ko: Record = { "prov.loginCancelled": "{provider} 로그인이 취소되었습니다", "prov.loginTimeout": "{provider} 로그인 시간 초과 — 브라우저를 닫았거나 완료되지 않았습니다. 다시 시도하세요.", "prov.loginOk": "{provider} 에 로그인했습니다. 모델을 표시하려면 {cmd} 를 실행하세요(또는 실시간 적용됩니다).", + "prov.loginSameAccount": "같은 {provider} 계정입니다. 브라우저에서 계정을 전환한 뒤 계정 추가를 다시 시도하세요.", "oauthTos.highTitle": "{provider}: 구독 OAuth 위험", "oauthTos.elevatedTitle": "{provider}: 비공식 OAuth 브리지", "oauthTos.anthropicBody": "Claude 구독 OAuth 토큰을 OpenCodex 같은 타사 프록시에서 직접 재사용하는 방식은 Anthropic이 지원하는 통합이 아니며 접근이 제한될 수 있습니다. Claude 구독을 사용하는 공식 Agent SDK 통합은 별도입니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c6940fde3c..050fb04c13 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -291,6 +291,7 @@ export const ru: Record = { "prov.loginCancelled": "Вход в {provider} отменён", "prov.loginTimeout": "Время ожидания входа в {provider} истекло — браузер был закрыт или вход не был завершён. Попробуйте ещё раз.", "prov.loginOk": "Выполнен вход в {provider}. Выполните {cmd} (или изменения применятся на лету), чтобы его модели появились в списке.", + "prov.loginSameAccount": "Это всё ещё тот же аккаунт {provider} — переключите аккаунт в браузере и снова нажмите «Добавить аккаунт».", "oauthTos.highTitle": "{provider}: риск OAuth по подписке", "oauthTos.elevatedTitle": "{provider}: неофициальный OAuth-мост", "oauthTos.anthropicBody": "Прямое повторное использование OAuth-токенов подписки Claude через сторонний прокси, такой как OpenCodex, не является поддерживаемой интеграцией Anthropic и может привести к ограничению доступа. Поддерживаемые интеграции Agent SDK, использующие подписки Claude, — это отдельный механизм.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3e026741e0..26ce9adf2e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -286,6 +286,7 @@ export const zh: Record = { "prov.loginCancelled": "{provider} 登录已取消", "prov.loginTimeout": "{provider} 登录超时 — 浏览器已关闭或未完成。请重试。", "prov.loginOk": "已登录到 {provider}。运行 {cmd}(或实时生效)以列出其模型。", + "prov.loginSameAccount": "仍是同一个 {provider} 账户 — 请在浏览器中切换账户后再次尝试添加账户。", "oauthTos.highTitle": "{provider}:订阅 OAuth 风险", "oauthTos.elevatedTitle": "{provider}:非官方 OAuth 桥接", "oauthTos.anthropicBody": "通过 OpenCodex 等第三方代理直接复用 Claude 订阅 OAuth 令牌,并非 Anthropic 支持的集成方式,可能导致访问受限。可使用 Claude 订阅的受支持 Agent SDK 集成属于另一种方式。", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 3d3677b412..22437e37fb 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -4,7 +4,7 @@ import ProviderDetails from "../components/provider-workspace/ProviderDetails"; import type { WorkspaceProvider } from "../provider-workspace/catalog"; import { ensureOpenAiProvider, openAiAccountProviderState, OpenAiEnableError } from "../provider-payload"; import { oauthTosRisk } from "../oauth-tos-risk"; -import { Notice } from "../ui"; +import { ToastNotice } from "../ui"; import { IconPlus } from "../icons"; import { useT } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; @@ -43,6 +43,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { const [codexLoginOpen, setCodexLoginOpen] = useState(false); const [modelsRefreshToken, setModelsRefreshToken] = useState(0); const [oauthTosPending, setOauthTosPending] = useState<{ provider: string; addAccount: boolean } | null>(null); + /** Bumped after OAuth login so ProviderDetails switches to the Accounts tab. */ + const [accountsFocusToken, setAccountsFocusToken] = useState(0); const aliveRef = useRef(true); const removeBusyRef = useRef(false); const oauthLoginGenerationRef = useRef>(new Map()); @@ -52,7 +54,26 @@ export default function Providers({ apiBase }: { apiBase: string }) { setStatusOk(ok); }, []); + const clearStatus = useCallback(() => { + setStatus(""); + setStatusOk(false); + }, []); + useEffect(() => { aliveRef.current = true; return () => { aliveRef.current = false; }; }, []); + + // Success toasts are transient; errors stay until the next notify or dismiss. + useEffect(() => { + if (!status || !statusOk) return; + const timer = window.setTimeout(clearStatus, 4500); + return () => window.clearTimeout(timer); + }, [status, statusOk, clearStatus]); + + const revealProviderAccounts = useCallback((provider: string) => { + setAdding(false); + setAddIntent(null); + setWorkspaceSelected(provider); + setAccountsFocusToken(token => token + 1); + }, []); // Providers hash sync is owned by App (passive replaceHash / deliberate navigateHash). // Warm the Add Provider catalog cache while the page is open so opening the @@ -122,7 +143,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { fetchConfig, fetchOauth, fetchProviderQuotas, codexActiveNeedsReauth, }); const { - accountSets, accountLoadStates, switchingAccount, keyPools, fetchAccountSets, + accountSets, setAccountSets, accountLoadStates, switchingAccount, keyPools, fetchAccountSets, switchAccount, switchApiKey, removeApiKey, addApiKeyValue, editCredentialAlias, removeAccount, activeAccountNeedsReauth, } = pools; @@ -159,9 +180,10 @@ export default function Providers({ apiBase }: { apiBase: string }) { const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1); const { cancelLoginOAuth, loginOAuth, logoutOAuth } = useProvidersOAuth({ - apiBase, t, aliveRef, oauthLoginGenerationRef, accountSets, + apiBase, t, aliveRef, oauthLoginGenerationRef, accountSets, setAccountSets, setBusy, setStatus, setLoginInfo, setOauthStatus, notify, fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, + onLoginSettled: revealProviderAccounts, }); const { removeProvider, confirmRemoveProvider, setProviderDisabled, updateProvider } = useProvidersCrud({ @@ -185,7 +207,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {

{t("nav.providers")}

{status - ? {status} + ? {status} : (
- {status && {status}} + {status && ( + {status} + )} } @@ -293,6 +321,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { accounts={accountSets[item.name]?.accounts ?? []} keys={keyPools[item.name] ?? []} accountLoadState={accountLoadStates[item.name] ?? (item.authMode === "oauth" ? "idle" : "ready")} + accountsFocusToken={workspaceSelected === item.name ? accountsFocusToken : 0} switchingAccountId={switchingAccount?.provider === item.name ? switchingAccount.accountId : null} busyProvider={busy} loginHint={loginInfo} @@ -348,6 +377,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onAccountLogin={onAccountLogin} onAccountCancelLogin={(provider) => { void cancelLoginOAuth(provider); }} onAccountLogout={(provider) => { void logoutOAuth(provider); }} + onAccountManage={onAccountManage} onOpenAdd={fetchOauth} onCloseCodexLogin={() => setCodexLoginOpen(false)} onCodexAdded={() => { diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx index 69099befa2..f9aafa5f72 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -25,6 +25,7 @@ export function ProvidersPageModals({ onAccountLogin, onAccountCancelLogin, onAccountLogout, + onAccountManage, onOpenAdd, onCloseCodexLogin, onCodexAdded, @@ -50,9 +51,10 @@ export function ProvidersPageModals({ oauthTosPending: { provider: string; addAccount: boolean } | null; onCloseAdd: () => void; onAdded: (name: string) => void; - onAccountLogin: (provider: string) => void; + onAccountLogin: (provider: string, addAccount?: boolean) => void; onAccountCancelLogin: (provider: string) => void; onAccountLogout: (provider: string) => void; + onAccountManage?: (provider: string) => void; onOpenAdd: () => void; onCloseCodexLogin: () => void; onCodexAdded: () => void; @@ -80,6 +82,7 @@ export function ProvidersPageModals({ onAccountLogin={onAccountLogin} onAccountCancelLogin={onAccountCancelLogin} onAccountLogout={onAccountLogout} + onAccountManage={onAccountManage} onOpen={onOpenAdd} /> )} diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index fc92bc4eed..4def2b328c 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -4,12 +4,15 @@ import { readJsonIfOk } from "../fetch-json"; import type { OAuthAccount, OAuthStatus } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; +type AccountSet = { activeAccountId: string | null; accounts: OAuthAccount[] }; + export function useProvidersOAuth({ apiBase, t, aliveRef, oauthLoginGenerationRef, accountSets, + setAccountSets, setBusy, setStatus, setLoginInfo, @@ -20,12 +23,14 @@ export function useProvidersOAuth({ fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, + onLoginSettled, }: { apiBase: string; t: TFn; aliveRef: React.MutableRefObject; oauthLoginGenerationRef: React.MutableRefObject>; - accountSets: Record; + accountSets: Record; + setAccountSets: React.Dispatch>>; setBusy: React.Dispatch>; setStatus: React.Dispatch>; setLoginInfo: React.Dispatch>; @@ -36,6 +41,8 @@ export function useProvidersOAuth({ fetchAccountSets: (providers: string[]) => Promise; fetchProviderQuotas: (refresh?: boolean) => Promise; bumpModelsRefresh: () => void; + /** Select the provider and open Accounts after a successful login. */ + onLoginSettled?: (provider: string) => void; }) { const cancelLoginOAuth = useCallback(async (provider: string) => { const gen = (oauthLoginGenerationRef.current.get(provider) ?? 0) + 1; @@ -89,8 +96,8 @@ export function useProvidersOAuth({ await new Promise(r => setTimeout(r, 2000)); if (oauthLoginGenerationRef.current.get(provider) !== generation || !aliveRef.current) return; const sRes = await fetch(`${apiBase}/api/oauth/status?provider=${provider}`).catch(() => null); - const s: (OAuthStatus & { accounts?: OAuthAccount[] }) | null = sRes - ? ((await readJsonIfOk(sRes)) ?? null) + const s: (OAuthStatus & { accounts?: OAuthAccount[]; activeAccountId?: string | null }) | null = sRes + ? ((await readJsonIfOk(sRes)) ?? null) : null; if (!s) continue; if (s.error) { @@ -106,8 +113,9 @@ export function useProvidersOAuth({ finished = true; break; } + const statusCount = s.accounts?.length ?? 0; const completed = addAccount || reauthTargetId - ? ((s.accounts?.length ?? 0) > baselineCount || s.done === true) + ? (statusCount > baselineCount || s.done === true) : (s.loggedIn || s.done === true); if (completed) { setOauthStatus(prev => ({ ...prev, [provider]: s })); @@ -126,13 +134,31 @@ export function useProvidersOAuth({ finished = true; break; } - notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), true); + // Seed the account list from the status poll immediately so Accounts does not + // briefly render empty while the follow-up /api/oauth/accounts round-trip runs. + if (s.accounts) { + setAccountSets(current => ({ + ...current, + [provider]: { + activeAccountId: s.activeAccountId ?? null, + accounts: s.accounts!, + }, + })); + } setLoginInfo(null); - fetchConfig(); + onLoginSettled?.(provider); const knownProviders = Object.keys(accountSets); const knownSet = new Set(knownProviders); - fetchAccountSets(knownSet.has(provider) ? knownProviders : [...knownProviders, provider]); - fetchProviderQuotas(true); + await fetchAccountSets(knownSet.has(provider) ? knownProviders : [...knownProviders, provider]); + if (!aliveRef.current || oauthLoginGenerationRef.current.get(provider) !== generation) return; + const sameIdentityAdd = addAccount && !reauthTargetId && statusCount <= baselineCount; + if (sameIdentityAdd) { + notify(t("prov.loginSameAccount", { provider: oauthLabel(provider) }), false); + } else { + notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), true); + } + void fetchConfig(); + void fetchProviderQuotas(true); bumpModelsRefresh(); finished = true; break; diff --git a/gui/src/styles.css b/gui/src/styles.css index 6732c457e9..19b7a20cec 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -897,6 +897,45 @@ select.input { appearance: none; } .notice { font-size: var(--text-control); line-height: var(--leading-body); padding: 9px 12px; border-radius: var(--radius-sm); margin-bottom: 14px; display: flex; align-items: center; gap: 8px; max-width: var(--prose-measure); } .notice svg { width: 15px; height: 15px; flex-shrink: 0; } + +/* Portaled status toast — out of document flow so Providers (etc.) do not reflow. */ +.toast-notice-host { + position: fixed; + inset: 0; + z-index: var(--z-modal); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 12vh 16px 16px; + pointer-events: none; +} +.toast-notice { + pointer-events: auto; + margin-bottom: 0; + max-width: min(var(--prose-measure), calc(100vw - 32px)); + box-shadow: var(--shadow); + animation: toast-notice-in var(--motion-normal) ease-out; +} +.toast-notice-copy { flex: 1; min-width: 0; } +.toast-notice-dismiss { + flex-shrink: 0; + border: 0; + background: transparent; + color: inherit; + opacity: 0.7; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 2px; +} +.toast-notice-dismiss:hover { opacity: 1; } +@keyframes toast-notice-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} +@media (prefers-reduced-motion: reduce) { + .toast-notice { animation: none; } +} /* Tone pairs: ink is a shade of the tint (not gray-on-color), AA on both themes. */ .notice-ok { background: light-dark(#ecfdf5, color-mix(in oklab, var(--green) 18%, var(--surface))); diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index d42c0fb93a..b10bf78066 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -23,6 +23,41 @@ export function Notice({ tone, children }: { tone: "ok" | "err"; children: React ); } +/** + * Fixed-position status toast. Portaled so it never consumes page flow / shifts layout. + * Parent owns auto-dismiss timing (success banners are typically transient). + */ +export function ToastNotice({ + tone, + children, + onDismiss, + dismissLabel = "Close", +}: { + tone: "ok" | "err"; + children: ReactNode; + onDismiss?: () => void; + dismissLabel?: string; +}) { + return createPortal( +
+
+ {tone === "ok" ? : } + {children} + {onDismiss && ( + + )} +
+
, + document.body, + ); +} + export interface SelectOption { value: string; label: React.ReactNode } export function Select({ value, options, onChange, disabled, label, id, style, align, placement, dropdownStyle, portal = true }: { value: string; diff --git a/src/oauth/cursor.ts b/src/oauth/cursor.ts index 09b1d9f4c1..6254425990 100644 --- a/src/oauth/cursor.ts +++ b/src/oauth/cursor.ts @@ -52,8 +52,8 @@ function decodeCursorJwtPayload(token: string): CursorJwtPayload | undefined { /** Build OAuthCredentials from Cursor tokens, extracting stable identity from JWT `sub` for multiauth. */ export function credentialsFromCursorTokens(accessToken: string, refreshToken: string): OAuthCredentials { const payload = decodeCursorJwtPayload(accessToken) ?? decodeCursorJwtPayload(refreshToken); - const accountId = typeof payload?.sub === "string" && payload.sub.length > 0 ? payload.sub : undefined; - const email = typeof payload?.email === "string" && payload.email.length > 0 ? payload.email.toLowerCase() : undefined; + const accountId = cursorJwtIdentity(payload?.sub); + const email = cursorJwtEmail(payload?.email); return { access: accessToken, refresh: refreshToken, @@ -63,11 +63,27 @@ export function credentialsFromCursorTokens(accessToken: string, refreshToken: s }; } +/** Coerce JWT `sub` (string or number) into a stable multiauth account id. */ +function cursorJwtIdentity(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function cursorJwtEmail(value: unknown): string | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + return value.toLowerCase(); +} + /** Generate PKCE params + the cursor.com deep-link login URL (challenge only — never the verifier). */ -export async function generateCursorAuthParams(): Promise { +export async function generateCursorAuthParams(opts?: { forceLogin?: boolean }): Promise { const { verifier, challenge } = await generatePKCE(); const uuid = crypto.randomUUID(); + // Cursor's deep-control page has no documented account-picker query; `mode=login` is the + // stable entry. forceLogin only changes the operator-facing instructions so a second/third + // account can be chosen in the already-open browser session before approving. const params = new URLSearchParams({ challenge, uuid, mode: "login", redirectTarget: "cli" }); + if (opts?.forceLogin) params.set("prompt", "login"); return { verifier, challenge, uuid, loginUrl: `${CURSOR_LOGIN_URL}?${params.toString()}` }; } @@ -139,9 +155,15 @@ export async function pollCursorAuth( export async function loginCursor( ctrl: OAuthController, pollBaseDelayMs: number = POLL_BASE_DELAY_MS, + opts?: { forceLogin?: boolean }, ): Promise { - const { verifier, uuid, loginUrl } = await generateCursorAuthParams(); - ctrl.onAuth?.({ url: loginUrl, instructions: "Approve the Cursor login in your browser, then return here." }); + const { verifier, uuid, loginUrl } = await generateCursorAuthParams({ forceLogin: opts?.forceLogin === true }); + ctrl.onAuth?.({ + url: loginUrl, + instructions: opts?.forceLogin + ? "In the browser, switch to the Cursor account you want to add, then approve the login and return here." + : "Approve the Cursor login in your browser, then return here.", + }); ctrl.onProgress?.("Waiting for Cursor login approval…"); const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier, ctrl.signal, pollBaseDelayMs); return credentialsFromCursorTokens(accessToken, refreshToken); diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 72e8c6d1f8..f9d5861c40 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -133,7 +133,7 @@ export const OAUTH_PROVIDERS: Record = { defaultModel: oauthDefaultModel("google-antigravity"), }, cursor: { - login: (ctrl) => loginCursor(ctrl), + login: (ctrl, opts) => loginCursor(ctrl, undefined, { forceLogin: opts?.forceLogin }), refresh: refreshCursorToken, providerConfig: oauthConfig("cursor"), defaultModel: oauthDefaultModel("cursor"), diff --git a/tests/cursor-oauth.test.ts b/tests/cursor-oauth.test.ts index 509528dcdf..864bafabcc 100644 --- a/tests/cursor-oauth.test.ts +++ b/tests/cursor-oauth.test.ts @@ -147,6 +147,20 @@ describe("Cursor OAuth core flow", () => { expect(creds.expires).toBe(exp * 1000 - 5 * 60 * 1000); }); + test("credentialsFromCursorTokens coerces numeric JWT sub for multiauth", () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const access = jwtWithExp(exp, { sub: 424242 }); + const creds = credentialsFromCursorTokens(access, "refresh-token"); + expect(creds.accountId).toBe("424242"); + }); + + test("generateCursorAuthParams adds prompt=login when forceLogin is set", async () => { + const p = await generateCursorAuthParams({ forceLogin: true }); + const url = new URL(p.loginUrl); + expect(url.searchParams.get("prompt")).toBe("login"); + expect(url.searchParams.get("mode")).toBe("login"); + }); + test("refreshCursorToken preserves accountId from the refreshed access token", async () => { const exp = Math.floor(Date.now() / 1000) + 3600; const access = jwtWithExp(exp, { sub: "google-oauth2|user_02XYZ" }); diff --git a/tests/oauth-store-multi.test.ts b/tests/oauth-store-multi.test.ts index 91b0d01b5f..0fd30eb2cd 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -132,6 +132,18 @@ describe("multi-account auth store", () => { expect(getCredential("cursor")?.access).toBe("access-b"); }); + test("cursor with a third distinct accountId appends without dropping prior accounts", async () => { + await saveCredential("cursor", cred({ accountId: "google-oauth2|user_a", access: "access-a" })); + await saveCredential("cursor", cred({ accountId: "auth0|user_b", access: "access-b" })); + await saveCredential("cursor", cred({ accountId: "google-oauth2|user_c", access: "access-c" })); + expect(listAccounts("cursor").map(a => a.credential.accountId)).toEqual([ + "google-oauth2|user_a", + "auth0|user_b", + "google-oauth2|user_c", + ]); + expect(getCredential("cursor")?.access).toBe("access-c"); + }); + test("chatgpt stays single-slot even with distinct identities", async () => { await saveCredential("chatgpt", cred({ email: "a@example.com", accountId: "one" })); await saveCredential("chatgpt", cred({ email: "b@example.com", accountId: "two", access: "b-access" })); From 28034c63c8b6de73d711dc4b479ce4077cfba09e Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:07:50 +0200 Subject: [PATCH 2/5] fix(gui): avoid accounts-focus effect that react-doctor flags Adjust the Accounts tab during render when accountsFocusToken changes, matching React's recommended pattern and clearing the CI warning. --- .../provider-workspace/ProviderDetails.tsx | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index dc3dd731d4..51ec33636b 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -2,7 +2,7 @@ * ProviderDetails — the detail header + tab shell (WP090+091). Owns tab state * and composes the Overview/Models/Usage/Settings panels. */ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useT } from "../../i18n/shared"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; import { formatProviderDisplayName } from "../../provider-icons"; @@ -94,7 +94,7 @@ export default function ProviderDetails({ const [pendingLeave, setPendingLeave] = useState(null); const [leaveSaving, setLeaveSaving] = useState(false); const settingsSaveRef = useRef<(() => Promise) | null>(null); - const lastAccountsFocusTokenRef = useRef(0); + const [seenAccountsFocusToken, setSeenAccountsFocusToken] = useState(accountsFocusToken); const registerSettingsSave = useCallback((save: (() => Promise) | null) => { settingsSaveRef.current = save; }, []); @@ -126,12 +126,19 @@ export default function ProviderDetails({ setTab(next); }, [tab, settingsDirty]); - useEffect(() => { - if (!accountsFocusToken || accountsFocusToken === lastAccountsFocusTokenRef.current) return; - lastAccountsFocusTokenRef.current = accountsFocusToken; - if (!authSurface) return; - switchTab("accounts"); - }, [accountsFocusToken, authSurface, switchTab]); + // Adjust related state when accountsFocusToken changes during render (not in an + // effect) so the Accounts tab is selected without a one-frame stale paint. + // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + if (accountsFocusToken !== seenAccountsFocusToken) { + setSeenAccountsFocusToken(accountsFocusToken); + if (accountsFocusToken && authSurface) { + if (settingsDirty && tab === "settings") { + setPendingLeave("accounts"); + } else { + setTab("accounts"); + } + } + } const requestDeselect = useCallback(() => { if (settingsDirty && tab === "settings") { From 5abdbbb0d65cd60bed05c56caadf657203b52b6c Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:22:53 +0200 Subject: [PATCH 3/5] fix(gui): keep mount-time Accounts focus pending until auth is ready Seed seenAccountsFocusToken at 0 and only advance it once authSurface exists so a reveal that mounts ProviderDetails with a non-zero token still opens Accounts. --- gui/src/components/provider-workspace/ProviderDetails.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 51ec33636b..6c2d776fe6 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -94,7 +94,9 @@ export default function ProviderDetails({ const [pendingLeave, setPendingLeave] = useState(null); const [leaveSaving, setLeaveSaving] = useState(false); const settingsSaveRef = useRef<(() => Promise) | null>(null); - const [seenAccountsFocusToken, setSeenAccountsFocusToken] = useState(accountsFocusToken); + // Seed 0 so a mount-time token from revealProviderAccounts stays pending until + // authSurface exists; seeding with the prop would treat it as already seen. + const [seenAccountsFocusToken, setSeenAccountsFocusToken] = useState(0); const registerSettingsSave = useCallback((save: (() => Promise) | null) => { settingsSaveRef.current = save; }, []); @@ -129,7 +131,9 @@ export default function ProviderDetails({ // Adjust related state when accountsFocusToken changes during render (not in an // effect) so the Accounts tab is selected without a one-frame stale paint. // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes - if (accountsFocusToken !== seenAccountsFocusToken) { + // Hold a non-zero token until authSurface exists so mount-time focus from + // revealProviderAccounts is not marked seen before Accounts can open. + if (accountsFocusToken !== seenAccountsFocusToken && !(accountsFocusToken && !authSurface)) { setSeenAccountsFocusToken(accountsFocusToken); if (accountsFocusToken && authSurface) { if (settingsDirty && tab === "settings") { From 70cc339d860b7404764f6e548c2c20dfd6211aae Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:35:29 +0200 Subject: [PATCH 4/5] fix(gui): scope Accounts focus token to the reveal target provider A leftover global token must not open Accounts when selecting another provider. Pair the counter with the target name and ignore mismatches; cover with a DOM regression test. --- .../provider-workspace/ProviderDetails.tsx | 13 +- gui/src/pages/Providers.tsx | 10 +- .../accounts-focus-provider-scope.test.tsx | 126 ++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 gui/tests/accounts-focus-provider-scope.test.tsx diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 6c2d776fe6..62cf94cad8 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -42,6 +42,7 @@ export default function ProviderDetails({ accounts, accountLoadState, accountsFocusToken = 0, + accountsFocusProvider = null, switchingAccountId, keys, busyProvider, @@ -72,8 +73,10 @@ export default function ProviderDetails({ oauth?: { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; accounts?: OAuthAccountRow[]; accountLoadState?: AccountLoadState; - /** When this token increases, switch to the Accounts tab (post-OAuth reveal). */ + /** When this token increases for accountsFocusProvider, switch to the Accounts tab. */ accountsFocusToken?: number; + /** Provider that owns the current accountsFocusToken; other providers ignore it. */ + accountsFocusProvider?: string | null; switchingAccountId?: string | null; keys?: ApiKeyRow[]; busyProvider?: string | null; @@ -104,6 +107,8 @@ export default function ProviderDetails({ const free = useMemo(() => isFreeProvider(item), [item]); const local = useMemo(() => isLocalProvider(item), [item]); const authSurface = useMemo(() => providerAuthSurface(item), [item]); + // Global counter from Providers — only honor it for the reveal target. + const scopedAccountsFocusToken = accountsFocusProvider === item.name ? accountsFocusToken : 0; const connectionIdentity = JSON.stringify([ codexController?.activeId ?? "", accounts?.find(account => account.active)?.id ?? "", @@ -133,9 +138,9 @@ export default function ProviderDetails({ // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes // Hold a non-zero token until authSurface exists so mount-time focus from // revealProviderAccounts is not marked seen before Accounts can open. - if (accountsFocusToken !== seenAccountsFocusToken && !(accountsFocusToken && !authSurface)) { - setSeenAccountsFocusToken(accountsFocusToken); - if (accountsFocusToken && authSurface) { + if (scopedAccountsFocusToken !== seenAccountsFocusToken && !(scopedAccountsFocusToken && !authSurface)) { + setSeenAccountsFocusToken(scopedAccountsFocusToken); + if (scopedAccountsFocusToken && authSurface) { if (settingsDirty && tab === "settings") { setPendingLeave("accounts"); } else { diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index be39b4eef2..bb566850e3 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -41,7 +41,10 @@ export default function Providers({ apiBase }: { apiBase: string }) { const [modelsRefreshToken, setModelsRefreshToken] = useState(0); const [oauthTosPending, setOauthTosPending] = useState<{ provider: string; addAccount: boolean } | null>(null); /** Bumped after OAuth login so ProviderDetails switches to the Accounts tab. */ - const [accountsFocusToken, setAccountsFocusToken] = useState(0); + const [accountsFocus, setAccountsFocus] = useState<{ token: number; provider: string | null }>({ + token: 0, + provider: null, + }); const aliveRef = useRef(true); // Which apiBase this instance has already bootstrapped. StrictMode double-invokes the mount // effect and its deferred load is deliberately uncancellable, so the guard lives here. @@ -71,7 +74,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { setAdding(false); setAddIntent(null); setWorkspaceSelected(provider); - setAccountsFocusToken(token => token + 1); + setAccountsFocus(previous => ({ token: previous.token + 1, provider })); }, []); // Providers hash sync is owned by App (passive replaceHash / deliberate navigateHash). @@ -339,7 +342,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { accounts={accountSets[item.name]?.accounts ?? []} keys={keyPools[item.name] ?? []} accountLoadState={accountLoadStates[item.name] ?? (item.authMode === "oauth" ? "idle" : "ready")} - accountsFocusToken={workspaceSelected === item.name ? accountsFocusToken : 0} + accountsFocusToken={accountsFocus.token} + accountsFocusProvider={accountsFocus.provider} switchingAccountId={switchingAccount?.provider === item.name ? switchingAccount.accountId : null} busyProvider={busy} loginHint={loginInfo} diff --git a/gui/tests/accounts-focus-provider-scope.test.tsx b/gui/tests/accounts-focus-provider-scope.test.tsx new file mode 100644 index 0000000000..20e6861ab2 --- /dev/null +++ b/gui/tests/accounts-focus-provider-scope.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ProviderDetails from "../src/components/provider-workspace/ProviderDetails"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +/** + * Regression: accountsFocusToken is a global counter. After a reveal for provider A, + * selecting provider B must not open B's Accounts tab from the leftover token. + */ + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const WINDOW_EVENT_STUB: { event: undefined } = { event: undefined }; +let previous: Record<(typeof globals)[number], unknown>; +let win: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof globalThis.fetch; + +function oauthItem(name: string): WorkspaceItem { + return { + name, + adapter: "openai-chat", + baseUrl: `https://${name}.invalid/v1`, + authMode: "oauth", + hasApiKey: false, + }; +} + +beforeEach(() => { + previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; + originalFetch = globalThis.fetch; + win = new Window({ url: "http://localhost/" }); + Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperty(win, "event", { configurable: true, writable: true, value: undefined }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: win.document }, + window: { configurable: true, value: win }, + navigator: { configurable: true, value: win.navigator }, + localStorage: { configurable: true, value: win.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: async () => ({ ok: true, json: async () => ({}) }) as unknown as Response, + }); + host = win.document.createElement("div") as unknown as HTMLElement; + win.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + await act(async () => { + for (let i = 0; i < 5; i++) { + await new Promise((r) => setTimeout(r, 0)); + await Promise.resolve(); + } + }); + for (const key of globals) { + let value = previous[key]; + if (key === "window") { + if (value == null || typeof value !== "object") { + value = WINDOW_EVENT_STUB; + } else if (!Object.prototype.hasOwnProperty.call(value, "event")) { + try { + Object.defineProperty(value, "event", { + configurable: true, + writable: true, + value: undefined, + }); + } catch { + value = WINDOW_EVENT_STUB; + } + } + } + Object.defineProperty(globalThis, key, { configurable: true, value }); + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch }); +}); + +async function mountDetails(item: WorkspaceItem, focus: { token: number; provider: string | null }) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + {}} + apiBase="" + accounts={[]} + accountLoadState="ready" + accountsFocusToken={focus.token} + accountsFocusProvider={focus.provider} + /> + , + ); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); +} + +function selectedTabId(): string | null { + const selected = host.querySelector('[role="tab"][aria-selected="true"]'); + return selected?.id ?? null; +} + +test("selecting provider B after A's focus request does not open B Accounts", async () => { + // Leftover global token from a reveal for alpha — beta must ignore it. + await mountDetails(oauthItem("beta"), { token: 3, provider: "alpha" }); + expect(selectedTabId()).toBe("pws-tab-overview"); +}); + +test("the reveal target still opens Accounts on mount-time token", async () => { + await mountDetails(oauthItem("alpha"), { token: 3, provider: "alpha" }); + expect(selectedTabId()).toBe("pws-tab-accounts"); +}); From 3bb1a3b7879ed602d523838cad7beaf9fe7063d9 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Sat, 8 Aug 2026 07:38:55 +0200 Subject: [PATCH 5/5] fix(gui): restart success toast timer on repeated identical notify Bump a status revision on each notify so the auto-dismiss effect re-arms when the same success message is shown again. --- gui/src/pages/Providers.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index bb566850e3..43e7adb67f 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -29,6 +29,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { const [adding, setAdding] = useState(false); const [status, setStatus] = useState(""); const [statusOk, setStatusOk] = useState(false); + /** 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 [busy, setBusy] = useState(null); @@ -54,6 +56,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const notify = useCallback((msg: string, ok: boolean = true) => { setStatus(msg); setStatusOk(ok); + setStatusRevision(revision => revision + 1); }, []); const clearStatus = useCallback(() => { @@ -68,7 +71,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { if (!status || !statusOk) return; const timer = window.setTimeout(clearStatus, 4500); return () => window.clearTimeout(timer); - }, [status, statusOk, clearStatus]); + }, [status, statusOk, statusRevision, clearStatus]); const revealProviderAccounts = useCallback((provider: string) => { setAdding(false);