diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index d723fd401d..21a7e702f0 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(); @@ -249,6 +250,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..ab980b0eac 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,33 @@ export default function ProviderCatalog({ )} ) : loggedIn ? ( - onLogout && + <> + {onManage && ( + + )} + {onLogin && ( + + )} + {busy && onCancelLogin && ( + + )} + {onLogout && !busy && ( + + )} + ) : busy ? ( onCancelLogin && ) : ( diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 155a762ef9..62cf94cad8 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -41,6 +41,8 @@ export default function ProviderDetails({ oauth, accounts, accountLoadState, + accountsFocusToken = 0, + accountsFocusProvider = null, switchingAccountId, keys, busyProvider, @@ -71,6 +73,10 @@ export default function ProviderDetails({ oauth?: { loggedIn: boolean; email?: string; error?: string; needsReauth?: boolean }; accounts?: OAuthAccountRow[]; accountLoadState?: AccountLoadState; + /** 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; @@ -91,6 +97,9 @@ export default function ProviderDetails({ const [pendingLeave, setPendingLeave] = useState(null); const [leaveSaving, setLeaveSaving] = useState(false); const settingsSaveRef = useRef<(() => Promise) | null>(null); + // 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; }, []); @@ -98,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 ?? "", @@ -122,6 +133,22 @@ export default function ProviderDetails({ setTab(next); }, [tab, settingsDirty]); + // 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 + // Hold a non-zero token until authSurface exists so mount-time focus from + // revealProviderAccounts is not marked seen before Accounts can open. + if (scopedAccountsFocusToken !== seenAccountsFocusToken && !(scopedAccountsFocusToken && !authSurface)) { + setSeenAccountsFocusToken(scopedAccountsFocusToken); + if (scopedAccountsFocusToken && authSurface) { + if (settingsDirty && tab === "settings") { + setPendingLeave("accounts"); + } else { + setTab("accounts"); + } + } + } + const requestDeselect = useCallback(() => { if (settingsDirty && tab === "settings") { setPendingLeave("deselect"); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 84bdf6eb81..59322ca427 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -352,6 +352,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 7d1c7964ec..529ec4d344 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -369,6 +369,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 b569245dbb..89d9db9c3d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -358,6 +358,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 92918b62e3..47ef2b5ded 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -361,6 +361,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 b78d79adda..435ddcd1e0 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -363,6 +363,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 dff991dc61..a45ae10c05 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -358,6 +358,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 736200b3f8..43e7adb67f 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"; @@ -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); @@ -40,6 +42,11 @@ 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 [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. @@ -49,9 +56,29 @@ 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(() => { + 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, statusRevision, clearStatus]); + + const revealProviderAccounts = useCallback((provider: string) => { + setAdding(false); + setAddIntent(null); + setWorkspaceSelected(provider); + setAccountsFocus(previous => ({ token: previous.token + 1, provider })); + }, []); // 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 @@ -139,7 +166,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; @@ -173,9 +200,10 @@ export default function Providers({ apiBase }: { apiBase: string }) { const bumpModelsRefresh = () => setModelsRefreshToken(n => n + 1); const { cancelLoginOAuth, loginOAuth, logoutOAuth } = useProvidersOAuth({ - apiBase, t, aliveRef, accountSets, + apiBase, t, aliveRef, accountSets, setAccountSets, setBusy, setStatus, setLoginInfo, setOauthStatus, notify, fetchConfig, fetchOauth, fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, + onLoginSettled: revealProviderAccounts, }); const { removeProvider, confirmRemoveProvider, setProviderDisabled, setDefaultProvider, updateProvider } = useProvidersCrud({ @@ -202,7 +230,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {

{t("nav.providers")}

{status - ? {status} + ? {status} : (
- {status && {status}} + {status && ( + {status} + )} } @@ -311,6 +345,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={accountsFocus.token} + accountsFocusProvider={accountsFocus.provider} switchingAccountId={switchingAccount?.provider === item.name ? switchingAccount.accountId : null} busyProvider={busy} loginHint={loginInfo} @@ -370,6 +406,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 58ab9fa2c7..92624f7dbf 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -26,6 +26,7 @@ export function ProvidersPageModals({ onAccountLogin, onAccountCancelLogin, onAccountLogout, + onAccountManage, onOpenAdd, onCloseCodexLogin, onCodexAdded, @@ -52,9 +53,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; @@ -82,6 +84,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 07f26decf3..456d33e61a 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -4,11 +4,14 @@ 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, accountSets, + setAccountSets, setBusy, setStatus, setLoginInfo, @@ -19,11 +22,13 @@ export function useProvidersOAuth({ fetchAccountSets, fetchProviderQuotas, bumpModelsRefresh, + onLoginSettled, }: { apiBase: string; t: TFn; aliveRef: React.MutableRefObject; - accountSets: Record; + accountSets: Record; + setAccountSets: React.Dispatch>>; setBusy: React.Dispatch>; setStatus: React.Dispatch>; setLoginInfo: React.Dispatch>; @@ -34,13 +39,20 @@ 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 oauthLoginGenerationRef = useRef | null>(null); if (oauthLoginGenerationRef.current === null) oauthLoginGenerationRef.current = new Map(); - const cancelLoginOAuth = useCallback(async (provider: string) => { + const bumpLoginGeneration = useCallback((provider: string) => { const gen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1; oauthLoginGenerationRef.current!.set(provider, gen); + return gen; + }, []); + + const cancelLoginOAuth = useCallback(async (provider: string) => { + const gen = bumpLoginGeneration(provider); try { await fetch(`${apiBase}/api/oauth/login/cancel`, { method: "POST", @@ -54,12 +66,10 @@ export function useProvidersOAuth({ setLoginInfo(current => current?.provider === provider ? null : current); } notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false); - }, [aliveRef, apiBase, notify, setBusy, setLoginInfo, t]); + }, [aliveRef, apiBase, bumpLoginGeneration, notify, setBusy, setLoginInfo, t]); const loginOAuth = async (provider: string, addAccount = false, accountId?: string) => { - const nextGen = (oauthLoginGenerationRef.current!.get(provider) ?? 0) + 1; - oauthLoginGenerationRef.current!.set(provider, nextGen); - const generation = nextGen; + const generation = bumpLoginGeneration(provider); const reauthTargetId = accountId?.trim() || undefined; setBusy(provider); setStatus(""); @@ -90,8 +100,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) { @@ -107,8 +117,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 })); @@ -127,13 +138,32 @@ 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) { + const activeFromRow = s.accounts.find(a => a.active)?.id ?? null; + setAccountSets(current => ({ + ...current, + [provider]: { + activeAccountId: s.activeAccountId ?? activeFromRow, + 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; @@ -158,6 +188,10 @@ export function useProvidersOAuth({ }; const logoutOAuth = async (provider: string) => { + // 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); try { const res = await fetch(`${apiBase}/api/oauth/logout?provider=${encodeURIComponent(provider)}`, { method: "POST" }); if (!res.ok) { diff --git a/gui/src/styles.css b/gui/src/styles.css index 6cb25f5386..b222f914f4 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1164,6 +1164,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 b76d0a2139..5907ac7071 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -26,6 +26,42 @@ export function Notice({ tone, children }: { tone: "ok" | "err" | "warn"; childr ); } +/** + * 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, +}: { + tone: "ok" | "err"; + children: ReactNode; + onDismiss?: () => void; + /** Required whenever onDismiss is provided — pass t("common.close"). */ + 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, id, label, describedBy, title, style, align, placement, dropdownStyle, portal = true }: { 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"); +}); diff --git a/src/oauth/cursor.ts b/src/oauth/cursor.ts index 09b1d9f4c1..d7607cef83 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,10 +63,24 @@ export function credentialsFromCursorTokens(accessToken: string, refreshToken: s }; } +/** Coerce JWT `sub` (string or safe integer) 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.isSafeInteger(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; only the stable + // PKCE params are sent. forceLogin is honored only in loginCursor instructions. const params = new URLSearchParams({ challenge, uuid, mode: "login", redirectTarget: "cli" }); return { verifier, challenge, uuid, loginUrl: `${CURSOR_LOGIN_URL}?${params.toString()}` }; } @@ -139,9 +153,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 + ? "Log out of Cursor in the browser (or use a private window), sign in as the account you want to add, then approve 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 3487023929..a8b2911410 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -204,7 +204,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..19abe77e4f 100644 --- a/tests/cursor-oauth.test.ts +++ b/tests/cursor-oauth.test.ts @@ -147,6 +147,28 @@ 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("credentialsFromCursorTokens rejects unsafe numeric JWT sub", () => { + const exp = Math.floor(Date.now() / 1000) + 3600; + const access = jwtWithExp(exp, { sub: Number.MAX_SAFE_INTEGER + 1 }); + const creds = credentialsFromCursorTokens(access, "refresh-token"); + expect(creds.accountId).toBeUndefined(); + }); + + test("generateCursorAuthParams keeps the documented PKCE URL even when forceLogin is set", async () => { + const p = await generateCursorAuthParams({ forceLogin: true }); + const url = new URL(p.loginUrl); + expect(url.searchParams.get("prompt")).toBeNull(); + expect(url.searchParams.get("mode")).toBe("login"); + expect(url.searchParams.get("redirectTarget")).toBe("cli"); + }); + 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 fcf09036c6..1e6b47e6b4 100644 --- a/tests/oauth-store-multi.test.ts +++ b/tests/oauth-store-multi.test.ts @@ -152,6 +152,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" }));