Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 17 additions & 21 deletions app/src/components/channels/DiscordConfig.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setChannelConnectionStatus({
channel: 'discord',
Expand Down
29 changes: 28 additions & 1 deletion app/src/components/channels/TelegramConfig.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
setChannelConnectionStatus({
channel: 'telegram',
Expand Down Expand Up @@ -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(
Expand Down
146 changes: 146 additions & 0 deletions app/src/hooks/__tests__/useOAuthConnectionListener.test.tsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<Provider store={store}>{children}</Provider>
);

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();
});
});
118 changes: 118 additions & 0 deletions app/src/hooks/useOAuthConnectionListener.ts
Original file line number Diff line number Diff line change
@@ -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<OAuthSuccessDetail>).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<OAuthErrorDetail>).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]);
}
Loading
Loading