From 68364d130d7bb175c55ed27b7d0e21ea7bd5c921 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:21:54 -0700 Subject: [PATCH 1/4] feat(slack): add installable connector page flow --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 97 +++++++++++- .../src/v2/components/V2ConnectorsPage.tsx | 149 +++++++++++++++--- 2 files changed, 225 insertions(+), 21 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 136496ccf3..ab3a87fc0a 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -168,6 +168,20 @@ describe('V2ConnectorsPage', () => { )); }); + it('installs Slack for the selected pod through the lifecycle verb', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { integration: { _id: 'slack-new' } } }); + renderPage(); + await screen.findByText(/Link a channel/); + fireEvent.click(screen.getByRole('button', { name: /Slack/ })); + fireEvent.click(screen.getByText('New Slack connector')); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/install', + { podId: 'p1' }, + expect.anything(), + )); + }); + it('keeps the add form open while an install claim is in progress', async () => { mockGets([]); axios.post.mockResolvedValue({ data: { status: 'installing' } }); @@ -245,6 +259,26 @@ describe('V2ConnectorsPage', () => { )); }); + it('disconnects an installable Slack connector through Slack\'s lifecycle verb', async () => { + mockGets([{ + _id: 'i-slack', + installationId: 'install-slack-u1', + type: 'slack', + status: 'connected', + config: { chatTitle: 'Commonly DM', liveRelay: true }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Manage' })); + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); + fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith( + '/api/installables/slack/install', + expect.anything(), + )); + }); + it('derives the installable lifecycle target from the connector row type', () => { expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install'); }); @@ -274,7 +308,68 @@ describe('V2ConnectorsPage', () => { mockGets([]); renderPage(); await screen.findByText(/Link a channel/); - expect(screen.queryByRole('button', { name: /Slack/ })).toBeNull(); + expect(screen.getByRole('button', { name: /Slack/ })).toBeInTheDocument(); expect(screen.getAllByText('SOON').length).toBeGreaterThanOrEqual(1); }); + + it('shows owner confirmation controls for a pending Slack authorization', async () => { + mockGets([{ + _id: 'i-slack-pending', + installationId: 'install-slack-u1', + type: 'slack', + status: 'pending', + config: { + pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam', expiresAt: new Date(Date.now() + 60_000).toISOString() }, + }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + axios.post.mockResolvedValue({ data: { status: 'connected' } }); + renderPage(); + expect(await screen.findByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Confirm connection' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/confirm', + {}, + expect.anything(), + )); + }); + + it('requests the server-built Slack authorization URL from the pending card', async () => { + mockGets([{ + _id: 'i-slack-authorize', + installationId: 'install-slack-u1', + type: 'slack', + status: 'pending', + config: { connectCode: 'a'.repeat(32), connectCodeExpiresAt: new Date(Date.now() + 60_000).toISOString() }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + axios.post.mockRejectedValue({ response: { status: 503 } }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Authorize in Slack' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/authorize-url', + {}, + expect.anything(), + )); + expect(await screen.findByText('Could not begin Slack authorization. Try again in a moment.')).toBeInTheDocument(); + }); + + it('lets the owner reject a pending Slack authorization', async () => { + mockGets([{ + _id: 'i-slack-pending', + installationId: 'install-slack-u1', + type: 'slack', + status: 'pending', + config: { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + axios.post.mockResolvedValue({ data: { status: 'rejected' } }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'This is not me' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/reject', + {}, + expect.anything(), + )); + }); }); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 16b9705f55..43be2fa83b 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -20,6 +20,13 @@ interface ConnectorConfig { connectCodeExpiresAt?: string; liveRelay?: boolean; relayAllAgentMessages?: boolean; + teamName?: string; + slackUserName?: string; + pendingBind?: { + teamName?: string; + slackUserName?: string; + expiresAt?: string; + }; } interface Connector { @@ -44,6 +51,10 @@ interface InstallErrorResponse { boundPodId?: string; } +interface SlackAuthorizeResponse { + authorizeUrl?: string; +} + const TYPE_LABELS: Record = { telegram: 'Telegram', discord: 'Discord', @@ -55,11 +66,11 @@ const TYPE_LABELS: Record = { instagram: 'Instagram', }; -// Spec §2.3 — only Telegram is self-serve today; the rest render as SOON -// tiles (45% opacity, not buttons — no dead clicks). +// Telegram and Slack are self-serve. The remaining provider tiles stay +// present as honest, non-interactive previews rather than dead buttons. const ADD_PLATFORMS: { type: string; enabled: boolean }[] = [ { type: 'telegram', enabled: true }, - { type: 'slack', enabled: false }, + { type: 'slack', enabled: true }, { type: 'discord', enabled: false }, { type: 'whatsapp', enabled: false }, ]; @@ -111,7 +122,7 @@ const V2ConnectorsPage: React.FC = () => { const [connectors, setConnectors] = useState([]); const [pods, setPods] = useState([]); const [newPodId, setNewPodId] = useState(''); - const [adding, setAdding] = useState(false); + const [addingType, setAddingType] = useState(null); const [loading, setLoading] = useState(true); const [busyId, setBusyId] = useState(null); const [creating, setCreating] = useState(false); @@ -120,6 +131,7 @@ const V2ConnectorsPage: React.FC = () => { const [copied, setCopied] = useState(null); const [error, setError] = useState(null); const pollRef = useRef | null>(null); + const adding = addingType !== null; const load = useCallback(async () => { try { @@ -135,8 +147,12 @@ const V2ConnectorsPage: React.FC = () => { useEffect(() => { load(); }, [load]); - // Spec §2.3 step 3: poll while any code is pending; stop when it connects. - const hasPending = connectors.some((c) => c.status !== 'connected' && codeIsLive(c)); + // Poll while a provider can finish binding: Telegram has a copied code; + // Slack either has an OAuth state or is awaiting the owner's confirmation. + const hasPending = connectors.some((c) => c.status !== 'connected' && ( + (c.type === 'telegram' && codeIsLive(c)) + || (c.type === 'slack' && (codeIsLive(c) || Boolean(c.config?.pendingBind))) + )); useEffect(() => { if (hasPending && !pollRef.current) { pollRef.current = setInterval(load, 3000); @@ -166,8 +182,10 @@ const V2ConnectorsPage: React.FC = () => { return () => { cancelled = true; }; }, [api]); - const createTelegram = async () => { - if (!newPodId || creating) return; + const createConnector = async () => { + if (!newPodId || !addingType || creating) return; + const type = addingType; + const typeLabel = TYPE_LABELS[type] || type; setCreating(true); setError(null); const installInProgressMessage = (boundPodId?: string): string => ( @@ -179,12 +197,12 @@ const V2ConnectorsPage: React.FC = () => { : t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' }) ); try { - const result = await api.post('/api/installables/telegram/install', { podId: newPodId }); + const result = await api.post(installableLifecyclePath(type), { podId: newPodId }); if (result.status && result.status !== 'active') { setError(installInProgressMessage(result.boundPodId)); return; } - setAdding(false); + setAddingType(null); await load(); } catch (error) { const response = installErrorResponse(error); @@ -192,7 +210,8 @@ const V2ConnectorsPage: React.FC = () => { setError(installInProgressMessage(response.data.boundPodId)); } else if (response.status === 409 && response.data?.code === 'already_installed') { setError(t('connectors.alreadyBound', { - defaultValue: 'Your Telegram channel is bound to {{pod}}. Disconnect it to bind a different pod.', + defaultValue: 'Your {{connector}} channel is bound to {{pod}}. Disconnect it to bind a different pod.', + connector: typeLabel, pod: boundPodName(response.data.boundPodId, connectors, pods), })); } else { @@ -231,6 +250,41 @@ const V2ConnectorsPage: React.FC = () => { } }; + const authorizeSlack = async (c: Connector) => { + if (busyId) return; + setBusyId(c._id); + setError(null); + try { + const result = await api.post('/api/installables/slack/authorize-url', {}); + if (!result.authorizeUrl) throw new Error('Slack authorization URL was missing'); + // This leaves Commonly only after the server has stored the browser + // nonce cookie that binds Slack's callback to this authorization attempt. + window.location.assign(result.authorizeUrl); + } catch { + setError(t('connectors.slackAuthorizeError', { + defaultValue: 'Could not begin Slack authorization. Try again in a moment.', + })); + } finally { + setBusyId(null); + } + }; + + const resolveSlackBind = async (c: Connector, action: 'confirm' | 'reject') => { + if (busyId) return; + setBusyId(c._id); + setError(null); + try { + await api.post(`/api/installables/slack/${action}`, {}); + await load(); + } catch { + setError(t('connectors.slackBindError', { + defaultValue: 'Could not update the Slack connection. Try again in a moment.', + })); + } finally { + setBusyId(null); + } + }; + const disconnect = async (c: Connector) => { setBusyId(c._id); try { @@ -280,6 +334,8 @@ const V2ConnectorsPage: React.FC = () => { const renderCard = (c: Connector) => { const st = statusLine(c); const isTelegram = c.type === 'telegram'; + const isSlack = c.type === 'slack'; + const supportsRelayControls = isTelegram || isSlack; const connected = c.status === 'connected'; const mirror = c.config?.relayAllAgentMessages === true; return ( @@ -298,7 +354,7 @@ const V2ConnectorsPage: React.FC = () => { {st.text} - {connected && isTelegram && ( + {connected && supportsRelayControls && ( + + )} + + {!connected && isSlack && c.config?.pendingBind && ( +
+
+ {t('connectors.slackConfirmHint', { + defaultValue: '{{workspace}} wants to connect as {{user}}.', + workspace: c.config.pendingBind.teamName || 'This Slack workspace', + user: c.config.pendingBind.slackUserName ? `@${c.config.pendingBind.slackUserName}` : 'your Slack user', + })} +
+ + +
+ )} ); }; - const hasTelegramInstallation = connectors.some( - (connector) => connector.type === 'telegram' && Boolean(connector.installationId), + const hasInstallation = (type: string): boolean => connectors.some( + (connector) => connector.type === type && Boolean(connector.installationId), ); const addTiles = (
- {ADD_PLATFORMS.map((p) => (p.enabled && !(p.type === 'telegram' && hasTelegramInstallation) ? ( - @@ -454,14 +558,19 @@ const V2ConnectorsPage: React.FC = () => { > {pods.map((p) => )} -

- {t('connectors.footnote', { defaultValue: 'You get a one-time code to send to the bot. More platforms are on the way.' })} + {addingType === 'slack' + ? t('connectors.slackFootnote', { defaultValue: 'Slack opens its secure authorization flow, then asks you to confirm the workspace and DM.' }) + : t('connectors.footnote', { defaultValue: 'You get a one-time code to send to the bot. More platforms are on the way.' })}

)} From 767843aab17d38968d03bb467f2090f87c18c1a6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:24:03 -0700 Subject: [PATCH 2/4] fix(slack): preserve oauth nonce cookie --- frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx | 2 +- frontend/src/v2/components/V2ConnectorsPage.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index ab3a87fc0a..eebf809ee8 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -349,7 +349,7 @@ describe('V2ConnectorsPage', () => { await waitFor(() => expect(axios.post).toHaveBeenCalledWith( '/api/installables/slack/authorize-url', {}, - expect.anything(), + expect.objectContaining({ withCredentials: true }), )); expect(await screen.findByText('Could not begin Slack authorization. Try again in a moment.')).toBeInTheDocument(); }); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 43be2fa83b..13de1d9122 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -255,7 +255,11 @@ const V2ConnectorsPage: React.FC = () => { setBusyId(c._id); setError(null); try { - const result = await api.post('/api/installables/slack/authorize-url', {}); + const result = await api.post( + '/api/installables/slack/authorize-url', + {}, + { withCredentials: true }, + ); if (!result.authorizeUrl) throw new Error('Slack authorization URL was missing'); // This leaves Commonly only after the server has stored the browser // nonce cookie that binds Slack's callback to this authorization attempt. From 49a80a643f84220cfc2561a4fff763d09bc6e6a6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:25:07 -0700 Subject: [PATCH 3/4] fix(slack): keep connector page open for oauth --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 30 ++++++++++++++++++- .../src/v2/components/V2ConnectorsPage.tsx | 20 +++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index eebf809ee8..8965c30b64 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -81,7 +81,12 @@ const renderPage = () => render( ); describe('V2ConnectorsPage', () => { - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(window, 'open').mockReturnValue(null); + }); + + afterEach(() => jest.restoreAllMocks()); it('lists connectors with grouped code, copy command, and dot status', async () => { mockGets(); @@ -354,6 +359,29 @@ describe('V2ConnectorsPage', () => { expect(await screen.findByText('Could not begin Slack authorization. Try again in a moment.')).toBeInTheDocument(); }); + it('opens Slack in a separate tab so the pending confirmation stays on this page', async () => { + const slackWindow = { + location: { assign: jest.fn() }, + close: jest.fn(), + opener: window, + }; + jest.mocked(window.open).mockReturnValue(slackWindow as unknown as Window); + mockGets([{ + _id: 'i-slack-authorize', + installationId: 'install-slack-u1', + type: 'slack', + status: 'pending', + config: { connectCode: 'a'.repeat(32), connectCodeExpiresAt: new Date(Date.now() + 60_000).toISOString() }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + axios.post.mockResolvedValue({ data: { authorizeUrl: 'https://slack.test/oauth?state=secret' } }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Authorize in Slack' })); + + await waitFor(() => expect(slackWindow.location.assign).toHaveBeenCalledWith('https://slack.test/oauth?state=secret')); + expect(slackWindow.opener).toBeNull(); + }); + it('lets the owner reject a pending Slack authorization', async () => { mockGets([{ _id: 'i-slack-pending', diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 13de1d9122..deca30f658 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -252,6 +252,10 @@ const V2ConnectorsPage: React.FC = () => { const authorizeSlack = async (c: Connector) => { if (busyId) return; + // Open the tab during the user gesture, before awaiting the API call, so + // browsers do not block it as an async popup. The main Commonly page then + // remains open to poll the callback's pending owner-confirmation state. + const authorizationWindow = window.open('', '_blank'); setBusyId(c._id); setError(null); try { @@ -261,10 +265,20 @@ const V2ConnectorsPage: React.FC = () => { { withCredentials: true }, ); if (!result.authorizeUrl) throw new Error('Slack authorization URL was missing'); - // This leaves Commonly only after the server has stored the browser - // nonce cookie that binds Slack's callback to this authorization attempt. - window.location.assign(result.authorizeUrl); + // The API has now stored the browser nonce cookie that binds Slack's + // callback to this authorization attempt. Drop the opener before this + // blank tab crosses to Slack, avoiding reverse-tabnabbing. + if (authorizationWindow) { + authorizationWindow.opener = null; + authorizationWindow.location.assign(result.authorizeUrl); + } else { + // A browser may still block a new tab. Continue rather than losing a + // user-initiated authorization attempt; the callback can be revisited + // through the browser's back button in that exceptional case. + window.location.assign(result.authorizeUrl); + } } catch { + authorizationWindow?.close(); setError(t('connectors.slackAuthorizeError', { defaultValue: 'Could not begin Slack authorization. Try again in a moment.', })); From bcb75a48d329f2ea336f912a675f06ab5f948e04 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:36:02 -0700 Subject: [PATCH 4/4] fix(slack): surface oauth callback state --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 45 +++++++++++++++++++ .../src/v2/components/V2ConnectorsPage.tsx | 33 ++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 8965c30b64..d489b88a82 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -84,6 +84,7 @@ describe('V2ConnectorsPage', () => { beforeEach(() => { jest.clearAllMocks(); jest.spyOn(window, 'open').mockReturnValue(null); + window.history.replaceState({}, '', '/v2/connectors'); }); afterEach(() => jest.restoreAllMocks()); @@ -400,4 +401,48 @@ describe('V2ConnectorsPage', () => { expect.anything(), )); }); + + it('shows a generic Slack callback failure and clears its opaque query code', async () => { + window.history.replaceState({}, '', '/v2/connectors?slack=error&code=invalid_state'); + mockGets([]); + + renderPage(); + + expect(await screen.findByRole('alert')).toHaveTextContent('Slack authorization didn’t complete — try again.'); + expect(window.location.search).toBe(''); + }); + + it('reloads connectors after a successful Slack callback and clears its query', async () => { + window.history.replaceState({}, '', '/v2/connectors?slack=pending'); + mockGets([]); + + renderPage(); + + await waitFor(() => expect( + axios.get.mock.calls.filter(([url]) => url === '/api/integrations/user/all').length, + ).toBeGreaterThanOrEqual(2)); + expect(window.location.search).toBe(''); + }); + + it('keeps an error-gated Slack row manageable so the user can disconnect and reconnect', async () => { + mockGets([{ + _id: 'i-slack-error', + installationId: 'install-slack-u1', + type: 'slack', + status: 'error', + config: { teamName: 'Commonly HQ', chatId: 'D1', chatType: 'im' }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); + renderPage(); + + expect(await screen.findByText('Connection error — reconnect')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Manage' })); + fireEvent.click(screen.getByRole('button', { name: 'Disconnect' })); + fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith( + '/api/installables/slack/install', + expect.anything(), + )); + }); }); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index deca30f658..200b72735e 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -130,6 +130,7 @@ const V2ConnectorsPage: React.FC = () => { const [confirmDisconnect, setConfirmDisconnect] = useState(null); const [copied, setCopied] = useState(null); const [error, setError] = useState(null); + const [slackCallbackError, setSlackCallbackError] = useState(null); const pollRef = useRef | null>(null); const adding = addingType !== null; @@ -147,6 +148,31 @@ const V2ConnectorsPage: React.FC = () => { useEffect(() => { load(); }, [load]); + // Slack returns through a separate browser tab. Its callback cannot render + // a useful Commonly page itself, so it redirects here with an intentionally + // opaque status. Consume the query once: URL error codes are never shown + // back to the user and therefore cannot become a UI data surface. + useEffect(() => { + const query = new URLSearchParams(window.location.search); + const slackState = query.get('slack'); + if (slackState !== 'pending' && slackState !== 'error') return; + if (slackState === 'pending') { + void load(); + } else { + setSlackCallbackError(t('connectors.slackCallbackError', { + defaultValue: 'Slack authorization didn’t complete — try again.', + })); + } + query.delete('slack'); + query.delete('code'); + const remaining = query.toString(); + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}${remaining ? `?${remaining}` : ''}${window.location.hash}`, + ); + }, [load, t]); + // Poll while a provider can finish binding: Telegram has a copied code; // Slack either has an OAuth state or is awaiting the owner's confirmation. const hasPending = connectors.some((c) => c.status !== 'connected' && ( @@ -355,6 +381,7 @@ const V2ConnectorsPage: React.FC = () => { const isSlack = c.type === 'slack'; const supportsRelayControls = isTelegram || isSlack; const connected = c.status === 'connected'; + const manageable = supportsRelayControls && (connected || c.status === 'error'); const mirror = c.config?.relayAllAgentMessages === true; return (
@@ -372,7 +399,7 @@ const V2ConnectorsPage: React.FC = () => { {st.text} - {connected && supportsRelayControls && ( + {manageable && (