diff --git a/app/src/components/channels/DiscordConfig.tsx b/app/src/components/channels/DiscordConfig.tsx
index 5c3459fd64..41f9dd36a7 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 451c3b6318..e28d362059 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 0000000000..558819d274
--- /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 0000000000..96f78bf1e3
--- /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 8efd668abf..3474bb43de 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 f984887298..064d7ea978 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;