From 7161ec28748b282f94d8c226990b606737506d19 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:35:24 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(v2):=20Connectors=20page=20+=20nav=20r?= =?UTF-8?q?ail=20entry=20=E2=80=94=20bridge=20pods=20to=20channels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure wiring over existing routes: GET /api/integrations/user/all, POST /api/integrations (telegram auto-mints connectCode), PATCH /:id (config.liveRelay persists since the schema declared it in #1282). Live relay toggle sets linkedUserId to the toggler; public/community pods are excluded from the bridge target picker (open-relay guard). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013pc6nGXRS8mHvrwcXMSRDK --- frontend/src/v2/V2App.tsx | 5 + .../v2/__tests__/V2ConnectorsPage.test.tsx | 128 +++++++++++ .../src/v2/components/V2ConnectorsPage.tsx | 215 ++++++++++++++++++ frontend/src/v2/components/V2NavRail.tsx | 1 + frontend/src/v2/v2.css | 105 +++++++++ 5 files changed, 454 insertions(+) create mode 100644 frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx create mode 100644 frontend/src/v2/components/V2ConnectorsPage.tsx diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index 29e68128d..b3c52a023 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -28,6 +28,7 @@ import './marketplace/V2MarketplaceDetailPage.css'; import AgentsHub from '../components/agents/AgentsHub'; import V2PersonaCatalog from './agents/V2PersonaCatalog'; import V2AgentBYO from './components/V2AgentBYO'; +import V2ConnectorsPage from './components/V2ConnectorsPage'; import V2PodBoard from './components/V2PodBoard'; import SkillsCatalogPage from '../components/skills/SkillsCatalogPage'; import ActivityFeedPage from '../components/activity/ActivityFeedPage'; @@ -289,6 +290,10 @@ const V2App: React.FC = () => { path="agents/byo" element={} /> + , false)} + /> , false, false)} diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx new file mode 100644 index 000000000..b8ffeb66f --- /dev/null +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -0,0 +1,128 @@ +// @ts-nocheck +// Connectors page: lists the user's channel bridges, surfaces the one-time +// /commonly-enable code while pending, and toggles live relay via PATCH with +// linkedUserId set to the toggler (the bridge's attribution identity). +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 { AuthContext } from '../../context/AuthContext'; + +jest.mock('axios', () => { + const mock = { + get: jest.fn(), + post: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + defaults: { baseURL: '', headers: { common: {} } }, + interceptors: { + request: { use: jest.fn(), eject: jest.fn() }, + response: { use: jest.fn(), eject: jest.fn() }, + }, + }; + return { __esModule: true, default: mock, ...mock }; +}); + +const axios = jest.requireMock('axios').default; + +const authValue = { + currentUser: { _id: 'u1', username: 'sam' }, + user: { _id: 'u1', username: 'sam' }, + token: 'user-jwt', + loading: false, + error: null, + isAuthenticated: true, + register: jest.fn(), + login: jest.fn(), + logout: jest.fn(), + updateProfile: jest.fn(), +}; + +const connectors = [ + { + _id: 'i-pending', + type: 'telegram', + status: 'pending', + config: { connectCode: 'abc123' }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }, + { + _id: 'i-live', + type: 'telegram', + status: 'connected', + config: { chatTitle: 'Rewire crew', liveRelay: false }, + podId: { _id: 'p2', name: 'Ops' }, + }, +]; + +const mockGets = (list = connectors) => { + axios.get.mockImplementation((url) => { + if (url === '/api/integrations/user/all') return Promise.resolve({ data: list }); + if (url === '/api/pods') { + return Promise.resolve({ + data: [ + { _id: 'p1', name: 'Rewire Live Demo', type: 'chat' }, + { _id: 'pub', name: 'Town Square', type: 'community' }, + ], + }); + } + return Promise.resolve({ data: [] }); + }); +}; + +const renderPage = () => render( + + + + + , +); + +describe('V2ConnectorsPage', () => { + beforeEach(() => jest.clearAllMocks()); + + it('lists connectors with pod, status, and the enable code while pending', async () => { + mockGets(); + renderPage(); + expect(await screen.findByText('Rewire Live Demo')).toBeInTheDocument(); + expect(screen.getByText(/\/commonly-enable abc123/)).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); + expect(screen.getByText(/Rewire crew/)).toBeInTheDocument(); + }); + + it('toggling live relay PATCHes liveRelay with the current user as linkedUserId', async () => { + mockGets(); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + const toggle = await screen.findByRole('checkbox'); + fireEvent.click(toggle); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { config: { liveRelay: true, linkedUserId: 'u1' } }, + expect.anything(), + )); + }); + + it('excludes public pods from the bridge target picker', async () => { + mockGets([]); + renderPage(); + await screen.findByText(/No connectors yet/); + const picker = screen.getByLabelText('Pod to bridge'); + const options = Array.from(picker.querySelectorAll('option')).map((o) => o.textContent); + expect(options).toContain('Rewire Live Demo'); + expect(options).not.toContain('Town Square'); + }); + + it('creates a telegram connector for the selected pod', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } }); + renderPage(); + await screen.findByText(/No connectors yet/); + fireEvent.click(screen.getByText('New Telegram connector')); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/integrations', + { podId: 'p1', type: 'telegram', config: {} }, + expect.anything(), + )); + }); +}); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx new file mode 100644 index 000000000..00461399b --- /dev/null +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -0,0 +1,215 @@ +// Connectors — the v2 surface for channel bridges (Telegram today; Slack and +// Discord rows render read-only from the same catalog when they exist). +// +// The page is pure wiring over routes that already exist: +// GET /api/integrations/user/all — my integrations, pod populated +// POST /api/integrations — telegram create auto-mints connectCode +// PATCH /api/integrations/:id — config merge (liveRelay persists since +// the Integration schema declared it) +// +// Live relay is the demoed "channel = attention surface" mode: inbound chat +// messages post into the pod as the linked user and wake mentioned agents; +// outbound agent messages cross back only through the escalation gate. + +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useV2Api } from '../hooks/useV2Api'; +import { useAuth } from '../../context/AuthContext'; +import { V2Pod } from '../hooks/useV2Pods'; + +interface ConnectorConfig { + chatTitle?: string; + connectCode?: string; + liveRelay?: boolean; +} + +interface Connector { + _id: string; + type: string; + status: string; + config?: ConnectorConfig; + podId?: { _id: string; name?: string } | string | null; +} + +const TYPE_LABELS: Record = { + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + whatsapp: 'WhatsApp', + messenger: 'Messenger', + groupme: 'GroupMe', + x: 'X', + instagram: 'Instagram', +}; + +const BOT_HANDLE = process.env.REACT_APP_TELEGRAM_BOT_HANDLE || ''; + +const podName = (c: Connector): string => ( + typeof c.podId === 'object' && c.podId ? (c.podId.name || 'Untitled pod') : 'Untitled pod' +); + +const V2ConnectorsPage: React.FC = () => { + const { t } = useTranslation(); + const api = useV2Api(); + const { currentUser } = useAuth(); + const [connectors, setConnectors] = useState([]); + const [pods, setPods] = useState([]); + const [newPodId, setNewPodId] = useState(''); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + try { + const data = await api.get('/api/integrations/user/all'); + setConnectors(Array.isArray(data) ? data : []); + } catch { + setError(t('connectors.loadError', { defaultValue: 'Could not load connectors.' })); + } finally { + setLoading(false); + } + }, [api, t]); + + useEffect(() => { load(); }, [load]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const data = await api.get('/api/pods'); + // Same guard as the BYO picker: never offer public/community pods as a + // bridge target — a channel bridge into a public room is an open relay. + const eligible = (Array.isArray(data) ? data : []).filter( + (p) => !['community', 'showcase'].includes((p as { type?: string }).type || ''), + ); + if (!cancelled) { + setPods(eligible); + setNewPodId((prev) => prev || eligible[0]?._id || ''); + } + } catch { /* pod picker just stays empty */ } + })(); + return () => { cancelled = true; }; + }, [api]); + + const createTelegram = async () => { + if (!newPodId || creating) return; + setCreating(true); + setError(null); + try { + await api.post('/api/integrations', { podId: newPodId, type: 'telegram', config: {} }); + await load(); + } catch { + setError(t('connectors.createError', { defaultValue: 'Could not create the connector.' })); + } finally { + setCreating(false); + } + }; + + const toggleLiveRelay = async (c: Connector) => { + if (busyId) return; + setBusyId(c._id); + setError(null); + const next = !c.config?.liveRelay; + try { + await api.patch(`/api/integrations/${c._id}`, { + config: { + liveRelay: next, + // Inbound messages post into the pod attributed to this account — + // the toggler owns the bridge identity. + ...(next ? { linkedUserId: currentUser?._id } : {}), + }, + }); + await load(); + } catch { + setError(t('connectors.toggleError', { defaultValue: 'Could not update live relay.' })); + } finally { + setBusyId(null); + } + }; + + return ( +
+
+ {loading && ( +
{t('connectors.loading', { defaultValue: 'Loading connectors…' })}
+ )} + {!loading && connectors.length === 0 && ( +
+ {t('connectors.empty', { defaultValue: 'No connectors yet. Link a channel below — your pod gets a voice where your team already talks.' })} +
+ )} + {connectors.map((c) => ( +
+
+ {TYPE_LABELS[c.type] || c.type} + + {c.status === 'connected' + ? t('connectors.connected', { defaultValue: 'Connected' }) + : t('connectors.pending', { defaultValue: 'Waiting for the channel' })} + +
+
+ {podName(c)} + {c.config?.chatTitle && ↔ {c.config.chatTitle}} +
+ {c.status !== 'connected' && c.type === 'telegram' && c.config?.connectCode && ( +
+ {t('connectors.enableHint', { defaultValue: 'In your Telegram group, add the Commonly bot' })} + {BOT_HANDLE ? ` (@${BOT_HANDLE.replace(/^@/, '')})` : ''} + {t('connectors.enableHintSend', { defaultValue: ' and send:' })} + /commonly-enable {c.config.connectCode} +
+ )} + {c.type === 'telegram' && c.status === 'connected' && ( + + )} +
+ ))} +
+ +
+

{t('connectors.newTitle', { defaultValue: 'Connect a channel' })}

+
+ + +
+

+ {t('connectors.footnote', { defaultValue: 'You get a one-time code to send in the group. More platforms are on the way.' })} +

+
+ + {error &&
{error}
} +
+ ); +}; + +export default V2ConnectorsPage; diff --git a/frontend/src/v2/components/V2NavRail.tsx b/frontend/src/v2/components/V2NavRail.tsx index 3abff6daa..c4fce1166 100644 --- a/frontend/src/v2/components/V2NavRail.tsx +++ b/frontend/src/v2/components/V2NavRail.tsx @@ -27,6 +27,7 @@ const NAV_ITEMS: NavItem[] = [ { key: 'pods', label: 'Pods', path: '/v2', icon: }, { key: 'agents', label: 'Agents', path: '/v2/agents', icon: }, { key: 'community', label: 'Community', path: '/v2/community', icon: }, + { key: 'connectors', label: 'Connectors', path: '/v2/connectors', icon: }, // 'Apps' (marketplace) removed from the rail while the marketplace is behind // its "coming soon" wall — a nav item that only leads to a coming-soon page // is a dead end. Restore this entry when MARKETPLACE_LOCKED is lifted. diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 758ea952c..e1f8d822b 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -7553,3 +7553,108 @@ body.modern-ui.v2-canvas { .v2-root:lang(zh) button.v2-inspector__tab { font-size: 12px; } + +/* ── Connectors ─────────────────────────────────────────────────────────── */ +/* Channel bridges (Telegram live relay et al). Cards follow the system: + 1px border, no shadow, tokens only. */ +.v2-connectors { + display: flex; + flex-direction: column; + gap: 20px; + max-width: 720px; +} +.v2-connectors__list { display: flex; flex-direction: column; gap: 12px; } +.v2-connectors__empty { + border: 1px dashed var(--v2-border-strong); + border-radius: var(--v2-radius); + padding: 20px; + color: var(--v2-text-tertiary); + font-size: 14px; +} +.v2-connectors__card { + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius); + background: var(--v2-surface); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} +.v2-connectors__card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.v2-connectors__type { font-weight: 650; color: var(--v2-text-primary); font-size: 14px; } +.v2-connectors__status { + font-size: 12px; + border-radius: var(--v2-radius-pill); + padding: 2px 10px; + white-space: nowrap; +} +.v2-connectors__status--connected { background: var(--v2-success-soft); color: var(--v2-success); } +.v2-connectors__status--pending { background: var(--v2-warning-soft); color: var(--v2-warning); } +.v2-connectors__meta { + display: flex; + gap: 10px; + color: var(--v2-text-secondary); + font-size: 13px; + flex-wrap: wrap; +} +.v2-connectors__chat { color: var(--v2-text-tertiary); } +.v2-connectors__enable { + font-size: 13px; + color: var(--v2-text-secondary); + background: var(--v2-bg-subtle); + border: 1px solid var(--v2-border-soft); + border-radius: var(--v2-radius-sm); + padding: 10px 12px; +} +.v2-connectors__enable code { + display: block; + margin-top: 6px; + font-family: var(--v2-font-mono); + font-size: 13px; + color: var(--v2-text-primary); + user-select: all; +} +.v2-connectors__relay { + display: flex; + gap: 10px; + align-items: flex-start; + font-size: 13px; + color: var(--v2-text-secondary); + cursor: pointer; +} +.v2-connectors__relay input { margin-top: 2px; accent-color: var(--v2-accent); } +.v2-connectors__new h2 { font-size: 15px; font-weight: 650; color: var(--v2-text-primary); margin: 0 0 10px; } +.v2-connectors__new-row { display: flex; gap: 10px; flex-wrap: wrap; } +.v2-connectors__select { + flex: 1; + min-width: 200px; + border: 1px solid var(--v2-border); + border-radius: var(--v2-radius-sm); + padding: 8px 10px; + font: inherit; + font-size: 14px; + color: var(--v2-text-primary); + background: var(--v2-surface); +} +.v2-connectors__create { + border: none; + border-radius: var(--v2-radius-sm); + background: var(--v2-accent); + color: #fff; + font-size: 14px; + font-weight: 600; + padding: 8px 16px; + cursor: pointer; + transition: background 80ms ease; +} +.v2-connectors__create:hover:not(:disabled) { background: var(--v2-accent-strong); } +.v2-connectors__create:disabled { opacity: 0.55; cursor: default; } +.v2-connectors__foot { font-size: 12px; color: var(--v2-text-muted); margin: 8px 0 0; } +.v2-connectors__error { + border: 1px solid var(--v2-danger); + background: var(--v2-danger-soft); + color: var(--v2-danger); + border-radius: var(--v2-radius-sm); + padding: 10px 12px; + font-size: 13px; +} From 94ce975ab076c1575e81515307348c8b49943919 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:42:39 -0700 Subject: [PATCH 2/3] =?UTF-8?q?test(v2):=20pod=20name=20matches=20card=20a?= =?UTF-8?q?nd=20picker=20option=20=E2=80=94=20use=20findAllByText?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013pc6nGXRS8mHvrwcXMSRDK --- frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index b8ffeb66f..143cef047 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -84,7 +84,8 @@ describe('V2ConnectorsPage', () => { it('lists connectors with pod, status, and the enable code while pending', async () => { mockGets(); renderPage(); - expect(await screen.findByText('Rewire Live Demo')).toBeInTheDocument(); + // The pod name renders in the card AND as a picker option — assert on both. + expect((await screen.findAllByText('Rewire Live Demo')).length).toBeGreaterThanOrEqual(1); expect(screen.getByText(/\/commonly-enable abc123/)).toBeInTheDocument(); expect(screen.getByText('Connected')).toBeInTheDocument(); expect(screen.getByText(/Rewire crew/)).toBeInTheDocument(); From f1e360ceec4a7bc3f230b49a06b475dea118cbaa Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:58:46 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(v2+api):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20server-derived=20linkedUserId,=20private-chat=20copy,=20loca?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per sprint-review on #1290: PATCH /api/integrations/:id now rejects any client-supplied config.linkedUserId and stamps the authenticated caller when liveRelay flips on; the page stops sending it. Per ux-lead: copy says private chat (matching #1289's gate), connectors.* locale entries added for en + zh-CN, and the pod select reuses .v2-byo__input. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013pc6nGXRS8mHvrwcXMSRDK --- .../routes/integrations.linkedUserId.test.js | 91 +++++++++++++++++++ backend/routes/integrations.ts | 9 ++ frontend/src/i18n/locales/en.json | 21 ++++- frontend/src/i18n/locales/zh-CN.json | 21 ++++- .../v2/__tests__/V2ConnectorsPage.test.tsx | 6 +- .../src/v2/components/V2ConnectorsPage.tsx | 18 ++-- frontend/src/v2/v2.css | 14 +-- 7 files changed, 154 insertions(+), 26 deletions(-) create mode 100644 backend/__tests__/unit/routes/integrations.linkedUserId.test.js diff --git a/backend/__tests__/unit/routes/integrations.linkedUserId.test.js b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js new file mode 100644 index 000000000..94cf5e10e --- /dev/null +++ b/backend/__tests__/unit/routes/integrations.linkedUserId.test.js @@ -0,0 +1,91 @@ +// PATCH /api/integrations/:id — bridge attribution guard. +// config.linkedUserId is the identity every inbound live-relay message is +// AUTHORED as (pod row, socket payload, agent wake). The route derives it from +// the authenticated caller when liveRelay flips on and rejects any +// client-supplied value: without this, any caller passing canDeleteIntegration +// could name someone else as the bridge author (sprint-review on #1290). +const request = require('supertest'); +const express = require('express'); + +jest.mock('../../../middleware/auth', () => (req, res, next) => { + req.user = { id: 'user-1' }; + next(); +}); +jest.mock('../../../middleware/adminAuth', () => (req, res, next) => next()); +jest.mock('../../../models/Pod', () => ({ findById: jest.fn() })); +jest.mock('../../../models/User', () => ({ findById: jest.fn() })); +jest.mock('../../../models/DiscordIntegration', () => function DiscordIntegration(data) { + Object.assign(this, data); + this.save = jest.fn().mockResolvedValue(this); +}); +jest.mock('../../../services/discordService', () => jest.fn()); +jest.mock('../../../models/Integration', () => { + function Integration(data) { Object.assign(this, data); } + Integration.findById = jest.fn(); + Integration.findByIdAndUpdate = jest.fn(); + Integration.aggregate = jest.fn().mockResolvedValue([]); + return Integration; +}); + +const Integration = require('../../../models/Integration'); +const User = require('../../../models/User'); +const Pod = require('../../../models/Pod'); +const integrationRoutes = require('../../../routes/integrations'); + +const app = express(); +app.use(express.json()); +app.use('/api/integrations', integrationRoutes); + +const telegramIntegration = () => ({ + _id: 'integration-1', + type: 'telegram', + podId: 'pod-1', + createdBy: { toString: () => 'user-1' }, + config: { + chatId: '42', + chatType: 'private', + toObject() { return { chatId: '42', chatType: 'private' }; }, + }, +}); + +describe('PATCH /api/integrations/:id — linkedUserId guard', () => { + beforeEach(() => { + jest.clearAllMocks(); + // canDeleteIntegration: non-admin caller who created the integration. + User.findById.mockResolvedValue({ _id: 'user-1', role: 'member' }); + Pod.findById.mockResolvedValue(null); + Integration.findById.mockResolvedValue(telegramIntegration()); + Integration.findByIdAndUpdate.mockResolvedValue({ _id: 'integration-1' }); + }); + + it('rejects a client-supplied linkedUserId naming someone else', async () => { + const res = await request(app) + .patch('/api/integrations/integration-1') + .send({ config: { liveRelay: true, linkedUserId: 'VICTIM-USER-ID' } }); + + expect(res.status).toBe(400); + expect(Integration.findByIdAndUpdate).not.toHaveBeenCalled(); + }); + + it('derives linkedUserId from the caller when liveRelay flips on', async () => { + const res = await request(app) + .patch('/api/integrations/integration-1') + .send({ config: { liveRelay: true } }); + + expect(res.status).toBe(200); + const [, update] = Integration.findByIdAndUpdate.mock.calls[0]; + expect(update.config.liveRelay).toBe(true); + expect(update.config.linkedUserId).toBe('user-1'); + }); + + it('does not stamp linkedUserId when liveRelay is switched off', async () => { + const res = await request(app) + .patch('/api/integrations/integration-1') + .send({ config: { liveRelay: false } }); + + expect(res.status).toBe(200); + const [, update] = Integration.findByIdAndUpdate.mock.calls[0]; + expect(update.config.liveRelay).toBe(false); + expect(update.config.linkedUserId).toBeUndefined(); + }); +}); diff --git a/backend/routes/integrations.ts b/backend/routes/integrations.ts index 62a192f0e..663d4eb54 100644 --- a/backend/routes/integrations.ts +++ b/backend/routes/integrations.ts @@ -394,7 +394,16 @@ router.patch('/:id', auth, async (req: AuthReq, res: Res) => { const canUpdate = await canDeleteIntegration(integration, req.user?.id || ''); if (!canUpdate) return res.status(403).json({ message: 'Access denied' }); const currentConfig = integration.config?.toObject ? integration.config.toObject() : (integration.config || {}) as Record; + // Bridge attribution guard: config.linkedUserId is the identity every + // inbound live-relay message is AUTHORED as (pod row, socket payload, + // agent wake). It is derived from the authenticated caller when liveRelay + // flips on — never accepted from the body, where it would let any caller + // who passes canDeleteIntegration name someone else as the bridge author. + if (config && 'linkedUserId' in config && String(config.linkedUserId) !== String(req.user?.id)) { + return res.status(400).json({ message: 'linkedUserId is derived from the authenticated caller and cannot be set' }); + } const nextConfig = config ? { ...currentConfig, ...config } : currentConfig; + if (config && config.liveRelay === true) nextConfig.linkedUserId = req.user?.id; const missingRequired = getMissingRequiredFields(integration.type || '', nextConfig); if (missingRequired.length && status === 'connected') return res.status(400).json({ message: `Missing required fields: ${missingRequired.join(', ')}`, missing: missingRequired }); validateManifestIfComplete(integration.type || '', nextConfig); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cdb59dff5..7602b75e4 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -15,7 +15,8 @@ "pods": "Pods", "agents": "Agents", "community": "Community", - "settings": "Settings" + "settings": "Settings", + "connectors": "Connectors" }, "closePodsList": "Close pods list", "communityRedirect": { @@ -1557,5 +1558,23 @@ "generic": "Could not open billing just now. Please try again.", "notConfigured": "Billing isn't switched on yet. Please contact us and we'll set you up." } + }, + "connectors": { + "loading": "Loading connectors…", + "loadError": "Could not load connectors.", + "createError": "Could not create the connector.", + "toggleError": "Could not update live relay.", + "empty": "No connectors yet. Link a channel below — your pod gets a voice where your team already talks.", + "connected": "Connected", + "pending": "Waiting for the channel", + "enableHint": "Open a private chat with the Commonly bot", + "enableHintSend": " and send:", + "liveRelay": "Live relay", + "liveRelayHint": "chat messages post into the pod and wake mentioned agents; agent escalations reach the channel.", + "newTitle": "Connect a channel", + "podPicker": "Pod to bridge", + "creating": "Creating…", + "createTelegram": "New Telegram connector", + "footnote": "You get a one-time code to send to the bot. More platforms are on the way." } } diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 88004bc70..8ea1ff699 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -15,7 +15,8 @@ "pods": "Pod", "agents": "智能体", "community": "社区", - "settings": "设置" + "settings": "设置", + "connectors": "连接器" }, "closePodsList": "关闭 Pod 列表", "communityRedirect": { @@ -1551,5 +1552,23 @@ "generic": "暂时无法打开付款页面,请稍后再试。", "notConfigured": "付款功能尚未开启,请联系我们为你开通。" } + }, + "connectors": { + "loading": "正在加载连接器…", + "loadError": "无法加载连接器。", + "createError": "无法创建连接器。", + "toggleError": "无法更新实时中继。", + "empty": "还没有连接器。在下方绑定一个频道,让你的 Pod 出现在团队已有的聊天工具里。", + "connected": "已连接", + "pending": "等待频道确认", + "enableHint": "打开与 Commonly 机器人的私聊", + "enableHintSend": ",然后发送:", + "liveRelay": "实时中继", + "liveRelayHint": "聊天消息会发布到 Pod 并唤醒被提及的智能体;智能体的升级消息会回到频道。", + "newTitle": "连接频道", + "podPicker": "要桥接的 Pod", + "creating": "创建中…", + "createTelegram": "新建 Telegram 连接器", + "footnote": "你会获得一个一次性代码,发送给机器人即可。更多平台即将支持。" } } diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx index 143cef047..3e20d191c 100644 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx @@ -91,7 +91,7 @@ describe('V2ConnectorsPage', () => { expect(screen.getByText(/Rewire crew/)).toBeInTheDocument(); }); - it('toggling live relay PATCHes liveRelay with the current user as linkedUserId', async () => { + it('toggling live relay PATCHes liveRelay only — linkedUserId is server-derived', async () => { mockGets(); axios.patch.mockResolvedValue({ data: {} }); renderPage(); @@ -99,7 +99,9 @@ describe('V2ConnectorsPage', () => { fireEvent.click(toggle); await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( '/api/integrations/i-live', - { config: { liveRelay: true, linkedUserId: 'u1' } }, + // No linkedUserId: the server stamps the authenticated caller and + // rejects a client-supplied value (impersonation guard, #1290 review). + { config: { liveRelay: true } }, expect.anything(), )); }); diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index 00461399b..714f39cbe 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -14,7 +14,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; -import { useAuth } from '../../context/AuthContext'; import { V2Pod } from '../hooks/useV2Pods'; interface ConnectorConfig { @@ -51,7 +50,6 @@ const podName = (c: Connector): string => ( const V2ConnectorsPage: React.FC = () => { const { t } = useTranslation(); const api = useV2Api(); - const { currentUser } = useAuth(); const [connectors, setConnectors] = useState([]); const [pods, setPods] = useState([]); const [newPodId, setNewPodId] = useState(''); @@ -112,13 +110,11 @@ const V2ConnectorsPage: React.FC = () => { setError(null); const next = !c.config?.liveRelay; try { + // linkedUserId is deliberately NOT sent: the server derives the bridge's + // attribution identity from the authenticated caller when liveRelay flips + // on, and rejects any client-supplied value (impersonation guard). await api.patch(`/api/integrations/${c._id}`, { - config: { - liveRelay: next, - // Inbound messages post into the pod attributed to this account — - // the toggler owns the bridge identity. - ...(next ? { linkedUserId: currentUser?._id } : {}), - }, + config: { liveRelay: next }, }); await load(); } catch { @@ -155,7 +151,7 @@ const V2ConnectorsPage: React.FC = () => { {c.status !== 'connected' && c.type === 'telegram' && c.config?.connectCode && (
- {t('connectors.enableHint', { defaultValue: 'In your Telegram group, add the Commonly bot' })} + {t('connectors.enableHint', { defaultValue: 'Open a private chat with the Commonly bot' })} {BOT_HANDLE ? ` (@${BOT_HANDLE.replace(/^@/, '')})` : ''} {t('connectors.enableHintSend', { defaultValue: ' and send:' })} /commonly-enable {c.config.connectCode} @@ -184,7 +180,7 @@ const V2ConnectorsPage: React.FC = () => {

{t('connectors.newTitle', { defaultValue: 'Connect a channel' })}