diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 09060afc40..136496ccf3 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', () => { @@ -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,12 +162,114 @@ 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('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('names the pending pod returned with a 202 install response', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { status: 'installing', 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('Still setting up for Rewire Live Demo — 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 pending pod when the server reports an install lock', async () => { + mockGets([]); + axios.post.mockRejectedValue({ + response: { status: 409, data: { code: 'install_in_progress', 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('Still setting up for Rewire Live Demo — 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' } }); + 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('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: {} }); + 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(), + )); + }); + + 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 6b181622d5..16b9705f55 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; @@ -31,6 +34,16 @@ interface Connector { podId?: { _id: string; name?: string } | string | null; } +interface InstallResponse { + status?: 'active' | 'installing' | 'activating' | 'uninstalling'; + boundPodId?: string; +} + +interface InstallErrorResponse { + code?: string; + boundPodId?: string; +} + const TYPE_LABELS: Record = { telegram: 'Telegram', discord: 'Discord', @@ -53,10 +66,30 @@ 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 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' ); +const boundPodName = (boundPodId: string | undefined, connectors: Connector[], pods: V2Pod[]): string => { + const existing = connectors.find((connector) => { + const podId = typeof connector.podId === 'object' ? connector.podId?._id : connector.podId; + return String(podId) === String(boundPodId); + }); + const boundPod = pods.find((pod) => String(pod._id) === String(boundPodId)); + return existing ? podName(existing) : (boundPod?.name || 'another pod'); +}; + // Codes are 32 hex now (128-bit) — grouped in 4s, wrapping; legacy short // codes flow through the same grouping (spec §2.3 step 2). const groupCode = (code: string): string => (code.match(/.{1,4}/g) || [code]).join(' '); @@ -137,12 +170,34 @@ const V2ConnectorsPage: React.FC = () => { if (!newPodId || creating) return; setCreating(true); setError(null); + const installInProgressMessage = (boundPodId?: string): string => ( + boundPodId + ? t('connectors.installInProgressForPod', { + defaultValue: 'Still setting up for {{pod}} — try again in a moment.', + pod: boundPodName(boundPodId, connectors, pods), + }) + : t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' }) + ); try { - await api.post('/api/integrations', { podId: newPodId, type: 'telegram', config: {} }); + const result = await api.post('/api/installables/telegram/install', { podId: newPodId }); + if (result.status && result.status !== 'active') { + setError(installInProgressMessage(result.boundPodId)); + 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(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.', + pod: boundPodName(response.data.boundPodId, connectors, pods), + })); + } else { + setError(t('connectors.createError', { defaultValue: 'Could not create the connector.' })); + } } finally { setCreating(false); } @@ -179,7 +234,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(installableLifecyclePath(c.type)); + } 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(); @@ -332,9 +394,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) ? (