diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 136496ccf3..d489b88a82 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -81,7 +81,13 @@ const renderPage = () => render( ); describe('V2ConnectorsPage', () => { - beforeEach(() => jest.clearAllMocks()); + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(window, 'open').mockReturnValue(null); + window.history.replaceState({}, '', '/v2/connectors'); + }); + + afterEach(() => jest.restoreAllMocks()); it('lists connectors with grouped code, copy command, and dot status', async () => { mockGets(); @@ -168,6 +174,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 +265,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 +314,135 @@ 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.objectContaining({ withCredentials: true }), + )); + 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', + 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(), + )); + }); + + 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 16b9705f55..200b72735e 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); @@ -119,7 +130,9 @@ 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; const load = useCallback(async () => { try { @@ -135,8 +148,37 @@ 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)); + // 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' && ( + (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 +208,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 +223,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 +236,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 +276,59 @@ 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 { + const result = await api.post( + '/api/installables/slack/authorize-url', + {}, + { withCredentials: true }, + ); + if (!result.authorizeUrl) throw new Error('Slack authorization URL was missing'); + // 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.', + })); + } 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,7 +378,10 @@ 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 manageable = supportsRelayControls && (connected || c.status === 'error'); const mirror = c.config?.relayAllAgentMessages === true; return (
@@ -298,7 +399,7 @@ const V2ConnectorsPage: React.FC = () => { {st.text} - {connected && isTelegram && ( + {manageable && ( + + )} + + {!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,19 +603,24 @@ 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.' })}

)} - {error &&
{error}
} + {(error || slackCallbackError) &&
{error || slackCallbackError}
} ); };