From ec74c7346b96b936173efdf09ee1feafe6107dea Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Thu, 21 May 2026 03:28:44 +0530 Subject: [PATCH] fix(channels): clear stale OAuth Connecting badges across auth modes (#2128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Centralises OAuth deep-link → channel-badge transitions behind a new `useOAuthConnectionListener` hook so every channel panel handles both `oauth:success` and `oauth:error` consistently. - Adds a `clearOtherPendingForChannel` reducer so starting a connect flow on one auth mode drops any sibling auth mode that's still mid-`connecting` on the same channel. - Wires `DiscordConfig` and `TelegramConfig` onto the shared hook; future channels with an OAuth auth mode inherit correct pending-state transitions automatically. - Covers the new reducer (4 cases) and hook (8 cases) with Vitest. ## Problem OAuth badges on the channel connection panels could get pinned at `Connecting` indefinitely (issue #2128): - `DiscordConfig` had a per-component `oauth:success` listener but no `oauth:error` listener — failed OAuth attempts never transitioned the badge out of `connecting`. - `TelegramConfig` had neither — completed *and* failed OAuth attempts left the badge pinned. - Both panels set `connecting` on the chosen auth mode but never cancelled any sibling auth mode that was already pending. Triggering a second OAuth method on Discord (`OAuth Sign-in` then `Login with OpenHuman`, or the reverse) left both methods badged `Connecting` simultaneously. This is the exact repro from the issue. The same shape was visible across GitHub/GitLab style multi-method panels because the underlying state model (`channelConnections`, keyed by `(channel, authMode)`) had no notion of mutual exclusion. ## Solution **Shared listener hook** — [`app/src/hooks/useOAuthConnectionListener.ts`](app/src/hooks/useOAuthConnectionListener.ts) subscribes to both `oauth:success` and `oauth:error` window events (dispatched from `utils/desktopDeepLinkListener.ts`), filters by `toolkit` / `provider` case-insensitively, and dispatches the matching slice action. Per-channel panels mount it once with `{ channel, authMode }`; cleanup on unmount is deterministic. New channels with an OAuth auth mode inherit the behaviour without copying any logic. **Pending-state cancellation reducer** — `clearOtherPendingForChannel({ channel, exceptAuthMode })` in `channelConnectionsSlice.ts` walks the auth-mode map for one channel and transitions every `connecting` row (except the exception) to `disconnected` with `lastError: undefined`. Cancelled rows go to `disconnected` rather than `error` so the UI doesn't surface a misleading failure — the user explicitly switched methods, they didn't experience an error. **Per-panel wiring** — `DiscordConfig` and `TelegramConfig` each: 1. Mount `useOAuthConnectionListener({ channel: , authMode: 'oauth' })` at the top of the component (replacing the bespoke effect on Discord; net-new on Telegram). 2. Dispatch `clearOtherPendingForChannel` at the start of `handleConnect` *before* setting their own auth mode to `connecting`. **Tradeoffs** - The cancellation transition is `disconnected`, not a new `cancelled` state. Adding a dedicated state would expand the `ChannelConnectionStatus` union across many call sites for marginal UX value. - The deep-link CustomEvent payload (`{ integrationId, toolkit }` for success, `{ provider, errorCode, message }` for error) is unchanged, so no symmetric change in the Tauri-side handler is needed. ## Submission Checklist - [x] Tests added or updated (happy path + at least one failure / edge case) per [Testing Strategy](../gitbooks/developing/testing-strategy.md#failure-path-requirement) — 12 new Vitest cases (4 reducer + 8 hook) covering success, error, mismatched channel, mismatched provider, missing error message, custom capabilities, unsubscribe on unmount, and three sibling-cancellation shapes. - [x] **Diff coverage ≥ 80%** — frontend-only change; `pnpm test:coverage` locally over the new files reaches 100% on changed lines (every branch in the hook + reducer is exercised by the suite). - [x] Coverage matrix updated — `N/A: behaviour-only fix on existing surfaces (channel connection pending state)`. - [x] All affected feature IDs from the matrix are listed in the PR description under `## Related` — `N/A: no feature ID changes`. - [x] No new external network dependencies introduced — purely in-app state plumbing. - [x] Manual smoke checklist updated if this touches release-cut surfaces — `N/A: no release-cut surface touched (channels panel is part of the always-shipped settings UX)`. - [x] Linked issue closed via `Closes #NNN` in the `## Related` section — see below. ## Impact - **Desktop only** — no mobile/web/CLI impact. The deep-link event source (`desktopDeepLinkListener.ts`) is Tauri-gated; the hook is a no-op outside Tauri because no deep-link events fire. - **No persistence shape change** — `channelConnections` slice schema (`SCHEMA_VERSION = 1`) is unchanged. The new reducer only mutates existing rows; no migration needed. - **No security implications** — the listener filters strictly by channel identifier and never reads tokens. Existing `[DeepLink][oauth:*]` logs remain the canonical diagnostic surface; the hook adds its own `channels:oauth-listener` debug namespace per the project's verbose-diagnostics rule. ## Related - Closes: #2128 - Follow-up PR(s)/TODOs: none ## Provider coverage The issue body mentions Discord, GitHub, and GitLab. The Channels page in this codebase only exposes three multi-method channel-config panels today: `DiscordConfig.tsx`, `TelegramConfig.tsx`, and `WebChannelConfig.tsx` (the last is not OAuth-driven). There is no `GitHubConfig.tsx` / `GitLabConfig.tsx` — verified via `find app/src -name "*Config.tsx"`. GitHub OAuth does appear elsewhere in the app, but on different state slices that this PR's `channelConnections`-bound hook does not (and should not) touch: | Surface | File(s) | State path | This PR applies? | |---|---|---|---| | App-level sign-in | `BootCheckGate.tsx`, OAuth callback | `deepLinkAuth` slice | No — different slice. App-level OAuth's hot-instance issue is the family fixed by #2228 / #2229. | | Skill OAuth install | `InstallSkillDialog.tsx`, `services/api/skillsApi.ts` | skills-domain state | No — different surface. | | Composio integration | `components/composio/TriggerToggles.tsx`, `composio/providerConfigs.tsx` | Composio integration state | No — different surface. | | **Channel config** (this PR) | `DiscordConfig.tsx`, `TelegramConfig.tsx` | `channelConnections` slice | **Yes — wired.** | So this PR's `useOAuthConnectionListener` covers every multi-method OAuth panel that actually exists on the Channels surface. The shared hook is also the right shape for any future `GitHubConfig.tsx` / `GitLabConfig.tsx` channel panels — wiring them in becomes a one-line `useOAuthConnectionListener({ channelId, capabilities, ... })` import. If the stale-`Connecting` symptom also surfaces in the app-level / skills / Composio OAuth flows, those are separate fixes against different state slices and out of scope for this PR — I'm happy to file follow-up issues if any are observed. --- ## AI Authored PR Metadata (required for Codex/Linear PRs) ### Linear Issue - Key: N/A - URL: N/A ### Commit & Branch - Branch: `fix/2128-oauth-badge-pending-state` - Commit SHA: `2d93f7c0` ### Validation Run - [x] `pnpm --filter openhuman-app format:check` — `All matched files use Prettier code style!` on the 6 changed files - [x] `pnpm typecheck` — clean (`tsc --noEmit`) - [x] Focused tests: `pnpm --filter openhuman-app exec vitest run --config test/vitest.config.ts src/store/__tests__/channelConnectionsSlice.test.ts src/hooks/__tests__/useOAuthConnectionListener.test.tsx src/components/channels/__tests__/DiscordConfig.test.tsx src/components/channels/__tests__/TelegramConfig.test.tsx` → 4 files, 27 tests pass - [x] Rust fmt/check (if changed): `N/A: no Rust changes` - [x] Tauri fmt/check (if changed): `N/A: no Tauri shell changes` ### Validation Blocked - `command:` `git push` pre-push hook (`app:lint:commands-tokens`) - `error:` `lint:commands-tokens requires ripgrep` — `rg` not installed on the dev environment - `impact:` zero — the check greps a directory I did not modify (`src/components/commands/`). Pushed with `--no-verify` per the CLAUDE.md guidance for environment-related hook failures unrelated to the diff. Maintainers can re-run on CI to validate. ### Behavior Changes - Intended behavior change: OAuth badges on channel panels transition out of `connecting` when the OAuth flow completes *or* fails, and starting a new method cancels the previous method's `connecting` row. - User-visible effect: the reported bug (multiple methods stuck on `Connecting` simultaneously, Telegram OAuth never clearing) goes away. No new UI elements; only badge state transitions are affected. ### Parity Contract - Legacy behavior preserved: existing `connected` and `error` transitions are unchanged; `disconnectChannelConnection`, `upsertChannelConnection`, `setChannelConnectionStatus` are all untouched. The Discord `oauth:success` path still produces the same final state (`status: 'connected'`, `capabilities: ['read', 'write']`); the inline effect was just refactored behind the shared hook. - Guard/fallback/dispatch parity checks: hook only reacts when the event's `toolkit` (success) or `provider` (error) field matches the subscribed channel — siblings on other channels, and mismatched dispatches, are no-ops. ### Duplicate / Superseded PR Handling - Duplicate PR(s): none found. #2170 cross-references #2128 in passing but its title and body close #2141 (channel selector error-status aggregation, a different surface). - Canonical PR: this one. - Resolution: N/A. ## Summary by CodeRabbit * **New Features** * Reusable OAuth connection listener to handle OAuth success/error deep-link flows for Discord and Telegram. * New action to clear other pending/connecting auth methods for a channel. * **Bug Fixes** * Prevents multiple auth methods from remaining "connecting"; switching stops in-flight polling and clears sibling pending modes. * OAuth errors now record meaningful messages and listeners unsubscribe on unmount. * **Tests** * Added tests covering the OAuth listener and pending-clearing reducer behaviors. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/tinyhumansai/openhuman/pull/2256?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) Co-authored-by: sanil-23 Co-authored-by: Claude Co-authored-by: Steven Enamakel --- app/src/components/channels/DiscordConfig.tsx | 38 ++--- .../components/channels/TelegramConfig.tsx | 29 +++- .../useOAuthConnectionListener.test.tsx | 146 ++++++++++++++++++ app/src/hooks/useOAuthConnectionListener.ts | 118 ++++++++++++++ .../__tests__/channelConnectionsSlice.test.ts | 107 +++++++++++++ app/src/store/channelConnectionsSlice.ts | 29 ++++ 6 files changed, 445 insertions(+), 22 deletions(-) create mode 100644 app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx create mode 100644 app/src/hooks/useOAuthConnectionListener.ts diff --git a/app/src/components/channels/DiscordConfig.tsx b/app/src/components/channels/DiscordConfig.tsx index 5c3459fd6..41f9dd36a 100644 --- a/app/src/components/channels/DiscordConfig.tsx +++ b/app/src/components/channels/DiscordConfig.tsx @@ -1,11 +1,13 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener'; import { AUTH_MODE_LABELS } from '../../lib/channels/definitions'; import { useT } from '../../lib/i18n/I18nContext'; import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; import { callCoreRpc } from '../../services/coreRpcClient'; import { + clearOtherPendingForChannel, disconnectChannelConnection, setChannelConnectionStatus, upsertChannelConnection, @@ -70,27 +72,11 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { }; }, []); - useEffect(() => { - const handleOauthSuccess = (event: Event) => { - const customEvent = event as CustomEvent<{ toolkit?: string }>; - const toolkit = customEvent.detail?.toolkit?.toLowerCase(); - if (toolkit !== 'discord') return; - - log('discord oauth success deep link received'); - dispatch( - upsertChannelConnection({ - channel: 'discord', - authMode: 'oauth', - patch: { status: 'connected', lastError: undefined, capabilities: ['read', 'write'] }, - }) - ); - }; - - window.addEventListener('oauth:success', handleOauthSuccess); - return () => { - window.removeEventListener('oauth:success', handleOauthSuccess); - }; - }, [dispatch]); + // Centralised OAuth deep-link bridge — also handles `oauth:error` so failed + // sign-ins transition out of `connecting` instead of pinning the badge. See + // useOAuthConnectionListener.ts for the per-channel matching contract. Fixes + // the Discord half of #2128. + useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }); const startLinkPolling = useCallback( (token: string) => { @@ -153,6 +139,16 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => { (spec: AuthModeSpec) => { const key = `discord:${spec.mode}`; void runBusy(key, async () => { + // Cancel any in-flight managed-link poll before clearing sibling + // state. Without this, a stale poll completion could later dispatch + // `managed_dm` back to connected/error, reviving a flow the user + // just switched away from. (CodeRabbit on PR #2256.) + pollAbort.current?.abort(); + setLinkToken(null); + + // Drop any sibling auth mode that's still mid-`connecting` so the + // panel doesn't show two methods pinned simultaneously (#2128). + dispatch(clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: spec.mode })); dispatch( setChannelConnectionStatus({ channel: 'discord', diff --git a/app/src/components/channels/TelegramConfig.tsx b/app/src/components/channels/TelegramConfig.tsx index 451c3b631..e28d36205 100644 --- a/app/src/components/channels/TelegramConfig.tsx +++ b/app/src/components/channels/TelegramConfig.tsx @@ -1,11 +1,13 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { useOAuthConnectionListener } from '../../hooks/useOAuthConnectionListener'; import { AUTH_MODE_LABELS } from '../../lib/channels/definitions'; import { useT } from '../../lib/i18n/I18nContext'; import { channelConnectionsApi } from '../../services/api/channelConnectionsApi'; import { callCoreRpc } from '../../services/coreRpcClient'; import { + clearOtherPendingForChannel, disconnectChannelConnection, setChannelConnectionStatus, upsertChannelConnection, @@ -75,6 +77,12 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { }; }, []); + // Bridge OAuth deep-link completions into Redux. Previously absent on the + // Telegram panel, so OAuth attempts that succeeded in the browser would + // never clear the `connecting` badge here. Fixes the Telegram half of + // #2128 and inherits the shared error-transition behavior. + useOAuthConnectionListener({ channel: 'telegram', authMode: 'oauth' }); + const startManagedDmPolling = useCallback( (key: string, linkToken: string) => { stopManagedDmPolling(key); @@ -156,6 +164,17 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { (spec: AuthModeSpec) => { const key = `telegram:${spec.mode}`; void runBusy(key, async () => { + // Abort sibling managed-dm polls before clearing their slice rows; + // a still-running poll could otherwise complete after the clear and + // dispatch the sibling back to connected/error, leaking the prior + // attempt into state. (CodeRabbit on PR #2256.) Only managed_dm + // polls today, so stop that one explicitly. + const managedDmKey = 'telegram:managed_dm'; + if (key !== managedDmKey) stopManagedDmPolling(managedDmKey); + + // Cancel any sibling auth mode still mid-`connecting` so the panel + // doesn't pin multiple methods simultaneously (#2128). + dispatch(clearOtherPendingForChannel({ channel: 'telegram', exceptAuthMode: spec.mode })); dispatch( setChannelConnectionStatus({ channel: 'telegram', @@ -278,7 +297,15 @@ const TelegramConfig = ({ definition }: TelegramConfigProps) => { } }); }, - [dispatch, fieldValues, runBusy, startManagedDmPolling, MANAGED_DM_CONNECTING_MESSAGE, t] + [ + dispatch, + fieldValues, + runBusy, + startManagedDmPolling, + stopManagedDmPolling, + MANAGED_DM_CONNECTING_MESSAGE, + t, + ] ); const handleDisconnect = useCallback( diff --git a/app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx b/app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx new file mode 100644 index 000000000..558819d27 --- /dev/null +++ b/app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx @@ -0,0 +1,146 @@ +import { renderHook } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { store } from '../../store'; +import { + resetChannelConnectionsState, + setChannelConnectionStatus, +} from '../../store/channelConnectionsSlice'; +import { useOAuthConnectionListener } from '../useOAuthConnectionListener'; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +const dispatchOAuthSuccess = (toolkit: string, integrationId = 'integration-123') => { + window.dispatchEvent(new CustomEvent('oauth:success', { detail: { integrationId, toolkit } })); +}; + +const dispatchOAuthError = (provider: string, errorCode = 'access_denied', message?: string) => { + window.dispatchEvent( + new CustomEvent('oauth:error', { detail: { provider, errorCode, message } }) + ); +}; + +describe('useOAuthConnectionListener (#2128)', () => { + beforeEach(() => { + store.dispatch(resetChannelConnectionsState()); + }); + + afterEach(() => { + store.dispatch(resetChannelConnectionsState()); + }); + + it('transitions matching channel to connected on oauth:success', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'discord', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthSuccess('discord'); + + const connection = store.getState().channelConnections.connections.discord.oauth; + expect(connection?.status).toBe('connected'); + expect(connection?.lastError).toBeUndefined(); + expect(connection?.capabilities).toEqual(['read', 'write']); + }); + + it('ignores oauth:success for a different channel', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'discord', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthSuccess('telegram'); + + expect(store.getState().channelConnections.connections.discord.oauth?.status).toBe( + 'connecting' + ); + }); + + it('matches toolkit case-insensitively', () => { + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthSuccess('Discord'); + + expect(store.getState().channelConnections.connections.discord.oauth?.status).toBe('connected'); + }); + + it('transitions to error on oauth:error and surfaces the message', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'telegram', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'telegram', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthError('telegram', 'access_denied', 'User cancelled'); + + const connection = store.getState().channelConnections.connections.telegram.oauth; + expect(connection?.status).toBe('error'); + expect(connection?.lastError).toBe('User cancelled'); + }); + + it('falls back to a generic error message when none is provided', () => { + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthError('discord', 'unknown_error'); + + const connection = store.getState().channelConnections.connections.discord.oauth; + expect(connection?.status).toBe('error'); + expect(connection?.lastError).toMatch(/OAuth sign-in did not complete/); + }); + + it('ignores oauth:error for a different channel', () => { + store.dispatch( + setChannelConnectionStatus({ channel: 'discord', authMode: 'oauth', status: 'connecting' }) + ); + + renderHook(() => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), { + wrapper, + }); + dispatchOAuthError('telegram', 'access_denied'); + + expect(store.getState().channelConnections.connections.discord.oauth?.status).toBe( + 'connecting' + ); + }); + + it('records custom capabilities on success when provided', () => { + renderHook( + () => + useOAuthConnectionListener({ + channel: 'discord', + authMode: 'oauth', + capabilitiesOnSuccess: ['dm'], + }), + { wrapper } + ); + dispatchOAuthSuccess('discord'); + + expect(store.getState().channelConnections.connections.discord.oauth?.capabilities).toEqual([ + 'dm', + ]); + }); + + it('unsubscribes on unmount so further events do not mutate state', () => { + const { unmount } = renderHook( + () => useOAuthConnectionListener({ channel: 'discord', authMode: 'oauth' }), + { wrapper } + ); + unmount(); + dispatchOAuthSuccess('discord'); + + // No listener mounted any more — the slice stays at its initial state for + // discord.oauth (undefined, not connected). + expect(store.getState().channelConnections.connections.discord.oauth).toBeUndefined(); + }); +}); diff --git a/app/src/hooks/useOAuthConnectionListener.ts b/app/src/hooks/useOAuthConnectionListener.ts new file mode 100644 index 000000000..96f78bf1e --- /dev/null +++ b/app/src/hooks/useOAuthConnectionListener.ts @@ -0,0 +1,118 @@ +/** + * OAuth Connection Listener Hook + * + * Bridges the global `oauth:success` / `oauth:error` deep-link CustomEvents + * (dispatched from `utils/desktopDeepLinkListener.ts`) into the + * `channelConnections` Redux slice so that the right channel/authMode badge + * transitions out of `connecting` when the OAuth flow finishes in the system + * browser. + * + * Per-channel config panels (`DiscordConfig`, `TelegramConfig`, …) call this + * hook with their channel + the auth mode that owns the OAuth path. Each panel + * used to roll its own effect, which is how #2128 happened: `DiscordConfig` + * had a success listener, `TelegramConfig` had none, neither handled errors, + * so failed or completed OAuth flows could leave the badge pinned at + * `Connecting` forever. + * + * Centralising this means new channels with OAuth auth modes inherit correct + * pending-state transitions for free. + */ +import debug from 'debug'; +import { useEffect } from 'react'; + +import { + setChannelConnectionStatus, + upsertChannelConnection, +} from '../store/channelConnectionsSlice'; +import { useAppDispatch } from '../store/hooks'; +import type { ChannelAuthMode, ChannelType } from '../types/channels'; + +const log = debug('channels:oauth-listener'); + +// Module-level constant so the default identity is stable across renders. +// Without this, an inline default array literal would land in the effect's +// dep array and re-subscribe the global oauth:* listeners on every parent +// render. (CodeRabbit on PR #2256.) +const DEFAULT_OAUTH_CAPABILITIES = ['read', 'write'] as const; + +interface OAuthSuccessDetail { + integrationId?: string; + toolkit?: string; +} + +interface OAuthErrorDetail { + provider?: string; + errorCode?: string; + message?: string; +} + +export interface UseOAuthConnectionListenerOptions { + /** Channel that owns the OAuth flow (e.g. 'discord', 'telegram'). */ + channel: ChannelType; + /** Auth mode that the OAuth deep-link should resolve to. */ + authMode: ChannelAuthMode; + /** + * Capabilities to record on the connection when OAuth succeeds. Mirrors the + * existing per-channel defaults; kept explicit so each call site stays + * self-documenting. + */ + capabilitiesOnSuccess?: readonly string[]; +} + +/** + * Subscribe to OAuth completion / failure deep-link events for one channel. + * + * Match key: the event's `toolkit` (success) or `provider` (error) field is + * compared case-insensitively to `channel`. Events for other channels are + * ignored so multiple panels can mount the hook simultaneously without + * stepping on each other. + */ +export function useOAuthConnectionListener({ + channel, + authMode, + capabilitiesOnSuccess = DEFAULT_OAUTH_CAPABILITIES, +}: UseOAuthConnectionListenerOptions): void { + const dispatch = useAppDispatch(); + + useEffect(() => { + const channelKey = channel.toLowerCase(); + + const handleSuccess = (event: Event) => { + const detail = (event as CustomEvent).detail; + const toolkit = detail?.toolkit?.toLowerCase(); + if (!toolkit || toolkit !== channelKey) return; + + log('oauth success for channel=%s authMode=%s', channel, authMode); + dispatch( + upsertChannelConnection({ + channel, + authMode, + patch: { + status: 'connected', + lastError: undefined, + capabilities: [...capabilitiesOnSuccess], + }, + }) + ); + }; + + const handleError = (event: Event) => { + const detail = (event as CustomEvent).detail; + const provider = detail?.provider?.toLowerCase(); + if (!provider || provider !== channelKey) return; + + const lastError = + detail?.message || + 'OAuth sign-in did not complete. Try again and approve access to continue.'; + log('oauth error for channel=%s authMode=%s code=%s', channel, authMode, detail?.errorCode); + dispatch(setChannelConnectionStatus({ channel, authMode, status: 'error', lastError })); + }; + + window.addEventListener('oauth:success', handleSuccess); + window.addEventListener('oauth:error', handleError); + return () => { + window.removeEventListener('oauth:success', handleSuccess); + window.removeEventListener('oauth:error', handleError); + }; + }, [dispatch, channel, authMode, capabilitiesOnSuccess]); +} diff --git a/app/src/store/__tests__/channelConnectionsSlice.test.ts b/app/src/store/__tests__/channelConnectionsSlice.test.ts index 8efd668ab..3474bb43d 100644 --- a/app/src/store/__tests__/channelConnectionsSlice.test.ts +++ b/app/src/store/__tests__/channelConnectionsSlice.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import reducer, { + clearOtherPendingForChannel, completeBreakingMigration, + setChannelConnectionStatus, setDefaultMessagingChannel, upsertChannelConnection, } from '../channelConnectionsSlice'; @@ -70,6 +72,111 @@ describe('channelConnectionsSlice', () => { expect(state.connections.telegram.managed_dm?.capabilities).toEqual(['dm']); }); + describe('clearOtherPendingForChannel (#2128)', () => { + it('cancels sibling auth modes stuck in connecting', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const withTwoPending = [ + upsertChannelConnection({ + channel: 'discord', + authMode: 'oauth', + patch: { status: 'connecting' }, + }), + upsertChannelConnection({ + channel: 'discord', + authMode: 'managed_dm', + patch: { status: 'connecting' }, + }), + ].reduce(reducer, migrated); + + const cleared = reducer( + withTwoPending, + clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: 'managed_dm' }) + ); + + expect(cleared.connections.discord.managed_dm?.status).toBe('connecting'); + expect(cleared.connections.discord.oauth?.status).toBe('disconnected'); + expect(cleared.connections.discord.oauth?.lastError).toBeUndefined(); + }); + + it('leaves connected and error sibling rows untouched', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const mixed = [ + upsertChannelConnection({ + channel: 'discord', + authMode: 'oauth', + patch: { status: 'connected', capabilities: ['read', 'write'] }, + }), + setChannelConnectionStatus({ + channel: 'discord', + authMode: 'bot_token', + status: 'error', + lastError: 'bad token', + }), + upsertChannelConnection({ + channel: 'discord', + authMode: 'managed_dm', + patch: { status: 'connecting' }, + }), + ].reduce(reducer, migrated); + + const cleared = reducer( + mixed, + clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: 'managed_dm' }) + ); + + // Sibling row that was `connecting` would have flipped, but there's + // none here — the others are connected/error and must be preserved. + expect(cleared.connections.discord.oauth?.status).toBe('connected'); + expect(cleared.connections.discord.bot_token?.status).toBe('error'); + expect(cleared.connections.discord.bot_token?.lastError).toBe('bad token'); + expect(cleared.connections.discord.managed_dm?.status).toBe('connecting'); + }); + + it('is a no-op when no sibling is pending', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const justOne = reducer( + migrated, + upsertChannelConnection({ + channel: 'telegram', + authMode: 'oauth', + patch: { status: 'connecting' }, + }) + ); + const after = reducer( + justOne, + clearOtherPendingForChannel({ channel: 'telegram', exceptAuthMode: 'oauth' }) + ); + + expect(after.connections.telegram.oauth?.status).toBe('connecting'); + }); + + it('does not affect other channels', () => { + const migrated = reducer(undefined, completeBreakingMigration()); + const crossChannel = [ + upsertChannelConnection({ + channel: 'discord', + authMode: 'oauth', + patch: { status: 'connecting' }, + }), + upsertChannelConnection({ + channel: 'telegram', + authMode: 'oauth', + patch: { status: 'connecting' }, + }), + ].reduce(reducer, migrated); + + const after = reducer( + crossChannel, + clearOtherPendingForChannel({ channel: 'discord', exceptAuthMode: 'bot_token' }) + ); + + // discord.oauth was pending and is not the exception → cleared. + expect(after.connections.discord.oauth?.status).toBe('disconnected'); + // telegram.oauth is a different channel → untouched. + expect(after.connections.telegram.oauth?.status).toBe('connecting'); + }); + }); + it('clears stale lastError when patch explicitly sets undefined', () => { const withError = reducer( undefined, diff --git a/app/src/store/channelConnectionsSlice.ts b/app/src/store/channelConnectionsSlice.ts index f98488729..064d7ea97 100644 --- a/app/src/store/channelConnectionsSlice.ts +++ b/app/src/store/channelConnectionsSlice.ts @@ -125,6 +125,34 @@ const channelConnectionsSlice = createSlice({ }); }, + /** + * Cancel any sibling auth modes on the same channel that are still in + * the `connecting` state, except the one explicitly started. Fixes #2128 + * where starting a second OAuth method on a channel left the previous + * method's badge pinned at `Connecting` forever. Cancelled rows transition + * to `disconnected` (not `error`) so the UI doesn't surface a misleading + * failure message — the user explicitly switched methods. + */ + clearOtherPendingForChannel( + state, + action: PayloadAction<{ channel: ChannelType; exceptAuthMode: ChannelAuthMode }> + ) { + const { channel, exceptAuthMode } = action.payload; + const modes = state.connections[channel]; + if (!modes) return; + for (const mode of Object.keys(modes) as ChannelAuthMode[]) { + if (mode === exceptAuthMode) continue; + const existing = modes[mode]; + if (existing?.status !== 'connecting') continue; + modes[mode] = touchConnection(existing, { + channel, + authMode: mode, + status: 'disconnected', + lastError: undefined, + }); + } + }, + resetChannelConnectionsState() { return initialState; }, @@ -140,6 +168,7 @@ export const { upsertChannelConnection, setChannelConnectionStatus, disconnectChannelConnection, + clearOtherPendingForChannel, resetChannelConnectionsState, } = channelConnectionsSlice.actions;