From b895bf976de8a32b37c92a212c9840f3800c3982 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:58:24 -0700 Subject: [PATCH 1/4] feat(connectors): install Telegram through connector lifecycle --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 34 +++++++++++++++++-- .../src/v2/components/V2ConnectorsPage.tsx | 14 ++++++-- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 09060afc40..5a0f289cde 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -49,6 +49,7 @@ const connectors = [ }, { _id: 'i-live', + installationId: 'install-telegram-u1', type: 'telegram', status: 'connected', config: { chatTitle: 'Rewire crew', liveRelay: false }, @@ -153,7 +154,7 @@ describe('V2ConnectorsPage', () => { expect(options).not.toContain('Town Square'); }); - it('creates a telegram connector for the selected pod', async () => { + it('installs Telegram for the selected pod', async () => { mockGets([]); axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } }); renderPage(); @@ -161,8 +162,35 @@ describe('V2ConnectorsPage', () => { fireEvent.click(screen.getByRole('button', { name: /Telegram/ })); fireEvent.click(screen.getByText('New Telegram connector')); await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/integrations', - { podId: 'p1', type: 'telegram', config: {} }, + '/api/installables/telegram/install', + { podId: 'p1' }, + expect.anything(), + )); + }); + + it('disconnects an installable Telegram connector through its lifecycle verb', async () => { + mockGets(); + 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/telegram/install', + expect.anything(), + )); + }); + + it('keeps a legacy Telegram connector disconnectable through the legacy route', async () => { + mockGets([{ ...connectors[1], installationId: undefined }]); + axios.patch.mockResolvedValue({ data: {} }); + 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.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { isActive: false }, expect.anything(), )); }); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 6b181622d5..3b6b82db2f 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -24,6 +24,9 @@ interface ConnectorConfig { interface Connector { _id: string; + // Installable-backed channel rows carry their parent binding. Older + // integrations remain managed through the legacy route until migrated. + installationId?: string; type: string; status: string; updatedAt?: string; @@ -138,7 +141,7 @@ const V2ConnectorsPage: React.FC = () => { setCreating(true); setError(null); try { - await api.post('/api/integrations', { podId: newPodId, type: 'telegram', config: {} }); + await api.post('/api/installables/telegram/install', { podId: newPodId }); setAdding(false); await load(); } catch { @@ -179,7 +182,14 @@ const V2ConnectorsPage: React.FC = () => { const disconnect = async (c: Connector) => { setBusyId(c._id); try { - await api.patch(`/api/integrations/${c._id}`, { isActive: false }); + if (c.installationId) { + await api.del('/api/installables/telegram/install'); + } else { + // The installable verb owns lifecycle for new Telegram bindings. Keep + // older direct integrations disconnectable while their migration is + // still an explicit, separate change. + await api.patch(`/api/integrations/${c._id}`, { isActive: false }); + } setConfirmDisconnect(null); setManageId(null); await load(); From 8d74e1027bb6cff9646cf9367c010a3db19099c7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:01:30 -0700 Subject: [PATCH 2/4] fix(connectors): derive lifecycle target from connector type --- frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx | 6 +++++- frontend/src/v2/components/V2ConnectorsPage.tsx | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 5a0f289cde..eefa200cd3 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; -import V2ConnectorsPage from '../components/V2ConnectorsPage'; +import V2ConnectorsPage, { installableLifecyclePath } from '../components/V2ConnectorsPage'; import { AuthContext } from '../../context/AuthContext'; jest.mock('axios', () => { @@ -181,6 +181,10 @@ describe('V2ConnectorsPage', () => { )); }); + it('derives the installable lifecycle target from the connector row type', () => { + expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install'); + }); + it('keeps a legacy Telegram connector disconnectable through the legacy route', async () => { mockGets([{ ...connectors[1], installationId: undefined }]); axios.patch.mockResolvedValue({ data: {} }); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 3b6b82db2f..3f02116b08 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -56,6 +56,13 @@ const ADD_PLATFORMS: { type: string; enabled: boolean }[] = [ const BOT_HANDLE = process.env.REACT_APP_TELEGRAM_BOT_HANDLE || ''; +// The route is keyed by the installable identity, which is the connector +// provider type in this Phase 1 projection. Do not pin this to Telegram: a +// future installable-backed provider must revoke its own parent. +export const installableLifecyclePath = (type: string): string => ( + `/api/installables/${encodeURIComponent(type)}/install` +); + const podName = (c: Connector): string => ( typeof c.podId === 'object' && c.podId ? (c.podId.name || 'Untitled pod') : 'Untitled pod' ); @@ -183,7 +190,7 @@ const V2ConnectorsPage: React.FC = () => { setBusyId(c._id); try { if (c.installationId) { - await api.del('/api/installables/telegram/install'); + await api.del(installableLifecyclePath(c.type)); } else { // The installable verb owns lifecycle for new Telegram bindings. Keep // older direct integrations disconnectable while their migration is From 7776407dd48ef7deb6856ef9614392447a64bbed Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:14:45 -0700 Subject: [PATCH 3/4] fix(connectors): surface install lifecycle state --- .../v2/__tests__/V2ConnectorsPage.test.tsx | 45 +++++++++++++++++++ .../src/v2/components/V2ConnectorsPage.tsx | 44 ++++++++++++++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index eefa200cd3..06c6d68cde 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -168,6 +168,44 @@ describe('V2ConnectorsPage', () => { )); }); + it('keeps the add form open while an install claim is in progress', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { status: 'installing' } }); + renderPage(); + await screen.findByText(/Link a channel/); + fireEvent.click(screen.getByRole('button', { name: /Telegram/ })); + fireEvent.click(screen.getByText('New Telegram connector')); + + expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument(); + expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument(); + }); + + it('keeps the add form open when the server reports an install lock', async () => { + mockGets([]); + axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress' } } }); + renderPage(); + await screen.findByText(/Link a channel/); + fireEvent.click(screen.getByRole('button', { name: /Telegram/ })); + fireEvent.click(screen.getByText('New Telegram connector')); + + expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument(); + expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument(); + }); + + it('names the existing pod when the installable is already bound elsewhere', async () => { + mockGets([]); + axios.post.mockRejectedValue({ + response: { status: 409, data: { code: 'already_installed', boundPodId: 'p1' } }, + }); + renderPage(); + await screen.findByText(/Link a channel/); + fireEvent.click(screen.getByRole('button', { name: /Telegram/ })); + fireEvent.click(screen.getByText('New Telegram connector')); + + expect(await screen.findByText(/bound to Rewire Live Demo/)).toBeInTheDocument(); + expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument(); + }); + it('disconnects an installable Telegram connector through its lifecycle verb', async () => { mockGets(); axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); @@ -199,6 +237,13 @@ describe('V2ConnectorsPage', () => { )); }); + it('does not offer a second Telegram install while an installable row exists', async () => { + mockGets(); + renderPage(); + await screen.findByText('Your channels'); + expect(screen.queryByRole('button', { name: /Telegram/ })).toBeNull(); + }); + it('SOON platforms are not buttons', async () => { mockGets([]); renderPage(); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 3f02116b08..643658e480 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -34,6 +34,15 @@ interface Connector { podId?: { _id: string; name?: string } | string | null; } +interface InstallResponse { + status?: 'active' | 'installing' | 'activating' | 'uninstalling'; +} + +interface InstallErrorResponse { + code?: string; + boundPodId?: string; +} + const TYPE_LABELS: Record = { telegram: 'Telegram', discord: 'Discord', @@ -63,6 +72,10 @@ export const installableLifecyclePath = (type: string): string => ( `/api/installables/${encodeURIComponent(type)}/install` ); +const installErrorResponse = (error: unknown): { status?: number; data?: InstallErrorResponse } => ( + (error as { response?: { status?: number; data?: InstallErrorResponse } })?.response || {} +); + const podName = (c: Connector): string => ( typeof c.podId === 'object' && c.podId ? (c.podId.name || 'Untitled pod') : 'Untitled pod' ); @@ -148,11 +161,30 @@ const V2ConnectorsPage: React.FC = () => { setCreating(true); setError(null); try { - await api.post('/api/installables/telegram/install', { podId: newPodId }); + const result = await api.post('/api/installables/telegram/install', { podId: newPodId }); + if (result.status && result.status !== 'active') { + setError(t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' })); + return; + } setAdding(false); await load(); - } catch { - setError(t('connectors.createError', { defaultValue: 'Could not create the connector.' })); + } catch (error) { + const response = installErrorResponse(error); + if (response.status === 409 && response.data?.code === 'install_in_progress') { + setError(t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' })); + } else if (response.status === 409 && response.data?.code === 'already_installed') { + const existing = connectors.find((connector) => { + const podId = typeof connector.podId === 'object' ? connector.podId?._id : connector.podId; + return String(podId) === String(response.data?.boundPodId); + }); + const boundPod = pods.find((pod) => String(pod._id) === String(response.data?.boundPodId)); + setError(t('connectors.alreadyBound', { + defaultValue: 'Your Telegram channel is bound to {{pod}}. Disconnect it to bind a different pod.', + pod: existing ? podName(existing) : (boundPod?.name || 'another pod'), + })); + } else { + setError(t('connectors.createError', { defaultValue: 'Could not create the connector.' })); + } } finally { setCreating(false); } @@ -349,9 +381,13 @@ const V2ConnectorsPage: React.FC = () => { ); }; + const hasTelegramInstallation = connectors.some( + (connector) => connector.type === 'telegram' && Boolean(connector.installationId), + ); + const addTiles = (
- {ADD_PLATFORMS.map((p) => (p.enabled ? ( + {ADD_PLATFORMS.map((p) => (p.enabled && !(p.type === 'telegram' && hasTelegramInstallation) ? (