diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 4294b18489..c1b0d2597a 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -69,8 +69,13 @@ module.exports = { // Connectors + Tools (TASK-164) — the channels list and the grants list // rendered English defaults under zh-CN: neither file was in this manifest, // so translationKeys.test.ts never saw the keys they were using. - 'src/v2/components/V2ConnectorsPage.tsx', - 'src/v2/components/V2ConnectorTools.tsx', + // Moved into src/v2/connectors by the tile-grid rebuild (TASK-733). + 'src/v2/connectors/ChannelPanel.tsx', + 'src/v2/connectors/ConnectorDrawer.tsx', + 'src/v2/connectors/ConnectorPopover.tsx', + 'src/v2/connectors/ConnectorTile.tsx', + 'src/v2/connectors/ConnectorsPage.tsx', + 'src/v2/connectors/ToolPanels.tsx', 'src/v2/utils/localizeRelativeTime.ts', ], rules: { diff --git a/frontend/src/i18n/__tests__/translationKeys.test.ts b/frontend/src/i18n/__tests__/translationKeys.test.ts index aa7dd1e3ae..6c46cb8608 100644 --- a/frontend/src/i18n/__tests__/translationKeys.test.ts +++ b/frontend/src/i18n/__tests__/translationKeys.test.ts @@ -44,7 +44,7 @@ describe('i18n migration manifest', () => { ); // Catalog description maps are locale-only: English renders the server's - // description, and a locale without a map falls back to it. See V2ConnectorsPage. + // description, and a locale without a map falls back to it. See ConnectorsPage. const LOCALE_ONLY_PREFIX = 'connectors.catalogDescriptions.'; it('keeps every registered language base key path identical', () => { diff --git a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts index a4e9ef7157..3b74aa6297 100644 --- a/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts +++ b/frontend/src/i18n/__tests__/zhNumberUnitSpacing.test.ts @@ -17,7 +17,7 @@ import path from 'path'; // be decided from this catalog: ``{{agents}}`` is a count in `yourTeam.head.meta_other` // and a name list joined with ' and ' in `inspector.workspace.nothingWorking_other`. // A first cut of the sweep declared `agents` a numeral by name and closed -// 「Scout 可以使用」 in the Tools panel; `V2ConnectorTools.test.tsx` caught it. So the +// 「Scout 可以使用」 in the Tools panel; `ConnectorsPage.tools.test.tsx` caught it. So the // default is opaque, the numeral names are declared here, and the coverage test // below makes a new interpolation fail rather than inherit whichever silence looks // convenient. @@ -57,7 +57,14 @@ const OPAQUE_PLACEHOLDERS = [ // The exception runs the safe way: a name that is usually opaque and is a number // here, named by key. `agents` is a count in yourTeam.head.meta (counts.agents) and // a name list in the inspector, so it cannot be declared for the catalog as a whole. -const NUMERAL_BY_KEY: Array<[string, string]> = [['yourTeam.head.meta_other', 'agents']]; +// `on`, `available` and `n` are counts in the Connectors page strings (the tile +// grid meta line and the "+N off" toggle), but opaque elsewhere. +const NUMERAL_BY_KEY: Array<[string, string]> = [ + ['yourTeam.head.meta_other', 'agents'], + ['connectors.meta', 'on'], + ['connectors.meta', 'available'], + ['connectors.offCount', 'n'], +]; // A digit is this rule's numeral only when it stands alone, and that is a question // about the TOKEN the digit sits inside, so it has two halves. `Apache-2.0 许可` and diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index dc348e8b37..e9362b64f7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2272,12 +2272,16 @@ } }, "connectors": { - "about": "About connectors", - "counts": { - "connected_one": "{{count}} connected", - "connected_other": "{{count}} connected", - "needsYou_one": "{{count}} needs you", - "needsYou_other": "{{count}} need you" + "search": "Search", + "filterLabel": "Show", + "channelsGroup": "Channels", + "addChannelTile": "Channel", + "meta": "{{on}} on · {{available}} available", + "offCount": "+{{n}} off", + "filter": { + "all": "All", + "channels": "Channels", + "tools": "Tools" }, "inactiveFold": "inactive · {{n}}", "loading": "Loading connectors…", @@ -2327,6 +2331,7 @@ "availableTelegram": "one message", "cancel": "Cancel", "channelDetails": "Channel details", + "closeDetails": "Close", "channelSees": "What the channel sees", "channels": "Your channels", "codeError": "Could not create a new code.", @@ -2335,7 +2340,6 @@ "connectChannel": "Connect a channel", "copied": "Copied", "copyCommand": "Copy command", - "description": "Connect a channel to a pod. Messages arrive there as you; choose what reaches the channel.", "errorLine": "The connection dropped.", "errorReconnect": "reconnect to resume", "gateLead": "Lead agent for {{pod}}", @@ -2480,6 +2484,9 @@ "loadFailed": "Could not load artifacts." }, "tools": { + "granted": "Granted", + "beta": "beta", + "adminOnce": "An admin installs this once", "emptyAudienceNotice": "Select at least one agent to grant access.", "makePodInviteOnly": "Make this pod invite-only", "podInviteOnlyNow": "This pod is invite-only. Select Grant to continue.", @@ -2528,7 +2535,6 @@ "grantDetails": "Grant details", "grantError": "Could not grant it.", "grantedByOn": "Granted by {{member}} {{rel}}.", - "grantedCount": "{{count}} granted", "grantedOn": "Granted {{rel}}.", "grantedToPod": "granted to {{pod}}", "grantedToPodBy": "granted to {{pod}} by {{member}}", @@ -2537,14 +2543,12 @@ "installationId": "installation ID", "installingGitHubApp": "Installing…", "keep": "Keep it", - "loading": "Loading tools…", "manage": "Manage", "mayUse": "{{agents}} may use it", "mode": "what it may do", "modeRead": "read", "modeWrite": "read and write", "modeWriteConfirm": "read and write, ask first", - "moreCount": "{{count}} more", "existingConnection": "existing connection", "noLongerOffered": "This connector is no longer offered here.", "removeConnection": "Remove connection", @@ -2584,7 +2588,6 @@ "revokeOld": "Revoke the old grant", "revokedByLine": "revoked by {{member}} {{rel}}", "revokedLine": "revoked {{rel}}", - "search": "Search tools", "segAll": "All", "segGranted": "Granted", "segNotYet": "Not yet", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index ea11b5b13a..8d80f7210c 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2262,12 +2262,16 @@ } }, "connectors": { - "about": "关于连接器", - "counts": { - "connected_one": "已连接{{count}}个频道", - "connected_other": "已连接{{count}}个频道", - "needsYou_one": "{{count}}项需要你处理", - "needsYou_other": "{{count}}项需要你处理" + "search": "搜索", + "filterLabel": "显示", + "channelsGroup": "频道", + "addChannelTile": "频道", + "meta": "已开启{{on}}个 · 可用{{available}}个", + "offCount": "另有{{n}}个未启用", + "filter": { + "all": "全部", + "channels": "频道", + "tools": "工具" }, "inactiveFold": "非活跃 · {{n}}", "loading": "正在加载连接器…", @@ -2317,6 +2321,7 @@ "availableTelegram": "发一条消息", "cancel": "取消", "channelDetails": "频道详情", + "closeDetails": "关闭", "channelSees": "频道可见内容", "channels": "你的频道", "codeError": "无法生成新的启用码。", @@ -2325,7 +2330,6 @@ "connectChannel": "连接频道", "copied": "已复制", "copyCommand": "复制命令", - "description": "将频道连接到 Pod。消息会以你的身份进入 Pod;你可以选择哪些内容回传到频道。", "errorLine": "连接已断开。", "errorReconnect": "重新连接以恢复", "gateLead": "{{pod}} 的主导智能体", @@ -2483,6 +2487,9 @@ "loadFailed": "无法加载产物。" }, "tools": { + "granted": "已授权", + "beta": "测试版", + "adminOnce": "需管理员安装一次", "emptyAudienceNotice": "请至少选择一个智能体以授予访问权限。", "makePodInviteOnly": "将此 Pod 设为仅限邀请", "podInviteOnlyNow": "此 Pod 现已设为仅限邀请。再次选择“授权”继续。", @@ -2531,7 +2538,6 @@ "grantDetails": "授权详情", "grantError": "无法授权。", "grantedByOn": "{{rel}}由 {{member}} 授权。", - "grantedCount": "已授权{{count}}个", "grantedOn": "{{rel}}授权。", "grantedToPod": "授权给 {{pod}}", "grantedToPodBy": "由 {{member}} 授权给 {{pod}}", @@ -2540,14 +2546,12 @@ "installationId": "安装 ID", "installingGitHubApp": "安装中…", "keep": "保留", - "loading": "正在加载工具…", "manage": "管理", "mayUse": "{{agents}} 可以使用", "mode": "可执行的操作", "modeRead": "读取", "modeWrite": "读取和写入", "modeWriteConfirm": "读取和写入,写入前需确认", - "moreCount": "另有{{count}}个", "existingConnection": "现有连接", "noLongerOffered": "此连接器已不在本实例提供。", "removeConnection": "移除连接", @@ -2587,7 +2591,6 @@ "revokeOld": "撤销旧授权", "revokedByLine": "{{rel}}由 {{member}} 撤销", "revokedLine": "{{rel}}撤销", - "search": "搜索工具", "segAll": "全部", "segGranted": "已授权", "segNotYet": "尚未授权", diff --git a/frontend/src/v2/V2App.tsx b/frontend/src/v2/V2App.tsx index a3a5119d81..25e1819d7f 100644 --- a/frontend/src/v2/V2App.tsx +++ b/frontend/src/v2/V2App.tsx @@ -38,7 +38,7 @@ const V2AgentSettingsPage = React.lazy(() => import('./agents/V2AgentSettingsPag const UserProfile = React.lazy(() => import('../components/UserProfile')); const V2AgentBYO = React.lazy(() => import('./components/V2AgentBYO')); const V2ConnectPage = React.lazy(() => import('./components/V2ConnectPage')); -const V2ConnectorsPage = React.lazy(() => import('./components/V2ConnectorsPage')); +const ConnectorsPage = React.lazy(() => import('./connectors/ConnectorsPage')); const V2PodBoard = React.lazy(() => import('./components/V2PodBoard')); const V2RunTrace = React.lazy(() => import('./components/V2RunTrace')); const V2ActivityPage = React.lazy(() => import('./components/V2ActivityPage')); @@ -301,10 +301,11 @@ const V2App: React.FC = () => { path="connectors" element={feature( 'Connectors', - , + , false, // The Signal artboard owns this page's display-sized head. false, + 'v2-feature--connectors', )} /> require('../__testkit__/axiosMock').axiosFactory())` +// cannot loop back through the modules that import axios. +export const axiosFactory = () => { + 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 }; +}; diff --git a/frontend/src/v2/__testkit__/connectorsKit.tsx b/frontend/src/v2/__testkit__/connectorsKit.tsx new file mode 100644 index 0000000000..9c5d3bf2f5 --- /dev/null +++ b/frontend/src/v2/__testkit__/connectorsKit.tsx @@ -0,0 +1,141 @@ +// @ts-nocheck +// Shared harness for the Connectors page suites (ConnectorsPage.channels / .tools / +// .layout). It is not a test file: it lives outside `__tests__` so jest does not +// collect it. A suite still declares the `jest.mock('axios', ...)` itself (babel-jest +// only hoists the call in the file that makes it), with `axiosMock.ts` as the factory. +import React from 'react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import ConnectorsPage from '../connectors/ConnectorsPage'; +import { AuthContext } from '../../context/AuthContext'; + +export const axios = jest.requireMock('axios').default; + +export 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(), +}; + +export const asUser = (user) => ({ ...authValue, currentUser: user, user }); +export const adminAuth = (over = {}) => asUser({ ...authValue.user, role: 'admin', ...over }); + +export const emptyCounts = { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 }; + +/** + * One GET router for every read the page makes. Anything not given answers the + * empty shape of its route, so a suite states only what it is about. + * + * connectors /api/integrations/user/all + * installables /api/installables (channels and tools share it); `null` rejects + * the read, and leaving it out answers `[]` (no catalogue) + * hidden hiddenToolConnections on the same response + * pods /api/pods (an Error rejects, 'pending' never settles) + * grants /api/grants + * seats { [podId]: seat[] } for /api/registry/pods/:id/agents + * trails { [grantId]: trail } for /api/grants/:id/calls + * route (url, config) => Promise | undefined, consulted first + */ +export const mockApi = ({ + connectors = [], + installables, + hidden, + pods = [], + grants = [], + seats = {}, + trails = {}, + route, +} = {}) => { + axios.get.mockImplementation((url, config) => { + const routed = route ? route(url, config) : undefined; + if (routed !== undefined) return routed; + if (url === '/api/integrations/user/all') return Promise.resolve({ data: connectors }); + if (url === '/api/installables') { + if (installables === null) return Promise.reject(new Error('catalogue unavailable')); + if (installables === undefined) return Promise.resolve({ data: [] }); + return Promise.resolve({ data: { installables, ...(hidden ? { hiddenToolConnections: hidden } : {}) } }); + } + if (url === '/api/pods') { + if (pods === 'pending') return new Promise(() => {}); + if (pods instanceof Error) return Promise.reject(pods); + return Promise.resolve({ data: pods }); + } + if (url === '/api/grants') return Promise.resolve({ data: { grants } }); + const seatRoute = /^\/api\/registry\/pods\/([^/]+)\/agents$/.exec(url); + if (seatRoute) return Promise.resolve({ data: { agents: seats[seatRoute[1]] || [] } }); + const callsRoute = /^\/api\/grants\/([^/]+)\/calls$/.exec(url); + if (callsRoute) { + return Promise.resolve({ data: trails[callsRoute[1]] || { grantId: callsRoute[1], calls: [], counts: emptyCounts } }); + } + return Promise.resolve({ data: [] }); + }); +}; + +export const renderPage = ({ auth = authValue, entries } = {}) => render( + + + + + , +); + +export const renderWithRoutes = (routes, { auth = authValue, entries = ['/v2/connectors'] } = {}) => render( + + {routes} + , +); + +// ---- the tile surface ---- +// A tile's accessible name is its dot's words plus its label, so a tile is found by +// the title it carries (the connector's label), never by role name. +export const tileNode = (name) => document.querySelector(`button.v2-cx-tile[title="${name}"]`); +export const findTile = async (name) => { + await waitFor(() => expect(tileNode(name)).not.toBeNull()); + return tileNode(name); +}; +export const clickTile = async (name) => { + const node = await findTile(name); + fireEvent.click(node); + return node; +}; +export const findDrawer = (name) => screen.findByRole('region', { name }); +export const findPopover = (name) => screen.findByRole('dialog', { name }); + +/** Opens the drawer of a connected tile and returns it. */ +export const openDrawer = async (name) => { + await clickTile(name); + return findDrawer(name); +}; +/** Opens the popover of an unconnected tile and returns it. */ +export const openPopover = async (name) => { + await clickTile(name); + return findPopover(name); +}; + +/** Opens a tool's drawer, then the grant row's selection to read its aside. */ +export const openGrant = async (toolName, rowName) => { + const drawer = await openDrawer(toolName); + fireEvent.click(within(drawer).getByRole('button', { name: rowName })); + const aside = await within(drawer).findByRole('complementary', { name: 'Grant details' }); + return { drawer, aside }; +}; + +/** The "+N off" button of a group ("Channels" or "Tools"), clicked open. */ +export const expandOff = async (group) => { + const section = await screen.findByRole('region', { name: group }); + fireEvent.click(await within(section).findByRole('button', { name: /^\+\d+ off$/ })); + return section; +}; + +/** Waits for the page's first read to settle (the loading line is gone). */ +export const pageReady = async () => { + await screen.findByRole('heading', { name: 'Connectors' }); + await waitFor(() => expect(screen.queryByText('Loading connectors…')).toBeNull()); +}; diff --git a/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx new file mode 100644 index 0000000000..2f71ccd3a3 --- /dev/null +++ b/frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx @@ -0,0 +1,1499 @@ +// @ts-nocheck +// Connectors page, channel half: every behaviour the old row page held for channels +// (Phase 1 verbs, Slack authorize/confirm, install lock, gates, catalogue states), +// reached now through a tile, its popover and the right drawer. +import React from 'react'; +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { Route, Routes } from 'react-router-dom'; +import { PlatformGlyph } from '../icons/platforms'; +import { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../connectors/channelModel'; +import en from '../../i18n/locales/en.json'; +import i18n, { i18nReady } from '../../i18n'; +import { + axios, authValue, mockApi, renderPage, renderWithRoutes, pageReady, tileNode, findTile, clickTile, + openDrawer, openPopover, findDrawer, expandOff, +} from '../__testkit__/connectorsKit'; + +jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory()); + +const connectors = [ + { + _id: 'i-pending', + installationId: 'install-telegram-u1', + type: 'telegram', + status: 'pending', + createdAt: new Date(Date.now() - 60_000).toISOString(), + config: { connectCode: 'abc123', connectCodeExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString() }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }, + { + _id: 'i-live', + installationId: 'install-slack-u1', + type: 'slack', + status: 'connected', + createdAt: new Date(Date.now() - 3_600_000).toISOString(), + updatedAt: new Date().toISOString(), + config: { chatTitle: 'Rewire crew', liveRelay: false }, + podId: { _id: 'p2', name: 'Ops' }, + }, +]; + +const roomPods = [ + { _id: 'p1', name: 'Rewire Live Demo', type: 'chat' }, + { _id: 'pub', name: 'Town Square', type: 'community' }, +]; + +// The page reads connectors, the catalogue and pods itself; a suite names only what +// it is about. +const mockGets = (list = connectors, installables = undefined, pods = roomPods) => ( + mockApi({ connectors: list, installables, pods }) +); + +const hostedLinearCatalogEntry = (connectionId = 'i-linear') => ({ + installableId: 'linear', + list: 'tools', + label: 'Linear', + description: 'Read Linear issues and projects.', + connectionType: 'hosted-mcp', + entryId: 'linear', + available: true, + tools: [], + connections: [{ connectionId, owner: 'sam', repo: '' }], + installation: null, + integration: null, +}); + +const slackPending = (config = {}, over = {}) => ({ + _id: 'i-slack-pending', + installationId: 'install-slack-u1', + type: 'slack', + status: 'pending', + config, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + ...over, +}); +const pendingBind = { pendingBind: { teamName: 'Commonly HQ', slackUserName: 'sam' } }; + +// The reconciler's own constant — the reason a person reads when the channel +// record behind a connector is gone (TASK-131 copy pass). +const REASON_CHANNEL_GONE = "This connector's channel is gone. Retry to rebuild it."; + +// The page-level slot a refusal must NOT land in when a drawer asked for it. +const pageError = () => document.querySelector('.v2-cx-error'); +// A tile's corner dot carries the row's sentence as its accessible name. +const dot = (name) => screen.getByRole('img', { name }); + +describe('Connectors page: channels', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(window, 'open').mockReturnValue(null); + window.history.replaceState({}, '', '/v2/connectors'); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('a linked channel tile carries its state sentence, and its drawer says what the channel sees', async () => { + mockGets([connectors[1]]); + renderPage(); + await findTile('Slack'); + expect(dot('Rewire crew · linked to Ops')).toBeInTheDocument(); + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByText('What the channel sees')).toBeInTheDocument(); + expect(within(drawer).getByText('Messages stay in the pod.')).toBeInTheDocument(); + }); + + // TASK-162: en.json is where the copy lives, so that is where the rule is asserted. + it('TASK-162: the relay-mode lines never repeat the mode word they sit beside', () => { + const kickerByRow: Array<[keyof typeof en.connectors, string]> = [ + ['rowAttention', 'attention'], + ['rowMirror', 'mirror'], + ['rowRelayOff', 'relay off'], + ]; + for (const [key, modeWord] of kickerByRow) { + expect(en.connectors[key]).toBeTruthy(); + expect(en.connectors[key].startsWith(modeWord)).toBe(false); + } + }); + + it('TASK-338: with nothing connected there is no unbuilt-provider placeholder and no not-yet or SOON copy', async () => { + mockGets([]); + renderPage(); + await pageReady(); + expect(screen.queryByText(/not yet/i)).toBeNull(); + expect(screen.queryByText(/Tell us which channel/)).toBeNull(); + expect(screen.queryByText('SOON')).toBeNull(); + expect(document.querySelector('.v2-connector-row__names')).toBeNull(); + }); + + it('TASK-131: relative ages advance in place, and a returning tab re-reads, without a reload', async () => { + // Fake the clock so the minute tick is deterministic. The gate line's age only + // moves if the page re-renders it, which is the defect being pinned. + jest.useFakeTimers(); + try { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); + mockGets([{ + ...connectors[1], + podId: 'p1', + config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { p1: { enabled: true, since: fiveMinutesAgo } } }, + }], undefined, [{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }]); + renderPage(); + + fireEvent.click(await findTile('Slack')); + expect(await screen.findByText(/^since 5m/)).toBeInTheDocument(); + + await act(async () => { jest.advanceTimersByTime(2 * 60_000); }); + expect(screen.getByText(/^since 7m/)).toBeInTheDocument(); + + const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/integrations/user/all').length; + const before = reads(); + document.dispatchEvent(new Event('visibilitychange')); + await waitFor(() => expect(reads()).toBeGreaterThan(before)); + } finally { + jest.useRealTimers(); + } + }); + + it('shows both channels as tiles and opens the pending one with its code and copy button', async () => { + mockGets(); + renderPage(); + + await findTile('Telegram'); + expect(dot('Send /commonly-enable in your Telegram chat.')).toBeInTheDocument(); + expect(dot('Rewire crew · linked to Ops')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Channels' }).querySelectorAll('.v2-cx-tile')).toHaveLength(2); + expect(document.querySelector('.v2-connector-row__names')).toBeNull(); + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText('/commonly-enable abc1 23')).toBeInTheDocument(); + expect(within(drawer).getByRole('button', { name: 'Copy command' })).toBeInTheDocument(); + }); + + // TASK-156. A linked Slack stores its workspace in `teamName` (slackOAuthService) + // and never in `chatTitle`, so the sentence read "Slack · linked to Ops" beside + // the word Slack. The workspace name belongs in the sentence; the tile's name + // stays the platform. + describe('the tile names the Slack workspace (TASK-156)', () => { + const slackRow = (config, podId = { _id: 'p2', name: 'Ops' }) => ({ + _id: 'i-slack', + installationId: 'install-slack-u1', + type: 'slack', + status: 'connected', + createdAt: new Date(Date.now() - 3_600_000).toISOString(), + updatedAt: new Date().toISOString(), + config, + podId, + }); + + it('reads the teamName when the link carries no chatTitle', async () => { + mockGets([slackRow({ teamName: 'Acme', liveRelay: false })]); + renderPage(); + + await findTile('Slack'); + expect(dot('Acme · linked to Ops')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Slack · linked to Ops' })).toBeNull(); + }); + + it('reads the teamName on the not-linked variant too', async () => { + mockGets([slackRow({ teamName: 'Acme', liveRelay: false }, null)]); + renderPage(); + + await findTile('Slack'); + expect(dot('Acme · not linked to a pod')).toBeInTheDocument(); + }); + + it('keeps chatTitle winning when the link carries both', async () => { + mockGets([slackRow({ chatTitle: 'Rewire crew', teamName: 'Acme', liveRelay: false })]); + renderPage(); + + await findTile('Slack'); + expect(dot('Rewire crew · linked to Ops')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Acme · linked to Ops' })).toBeNull(); + }); + }); + + it('offers a new code in the drawer when the Telegram code has expired', async () => { + mockGets([{ ...connectors[0], config: { connectCode: 'abc123' } }]); + axios.post.mockResolvedValue({ data: {} }); + renderPage(); + + await findTile('Telegram'); + expect(dot('The enable code expired.')).toBeInTheDocument(); + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText('The enable code expired.')).toBeInTheDocument(); + expect(screen.queryByText('/commonly-enable abc1 23')).toBeNull(); + fireEvent.click(within(drawer).getByRole('button', { name: 'New code' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/integrations/i-pending/connect-code', + {}, + expect.anything(), + )); + }); + + it('opens a connected channel and keeps relay and mode PATCHes in its drawer', async () => { + mockGets(); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('checkbox', { name: 'Relay' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { config: { liveRelay: true } }, + expect.anything(), + )); + }); + + it('PATCHes the mirror mode from the channel drawer', async () => { + mockGets([{ + ...connectors[1], + config: { chatTitle: 'Rewire crew', liveRelay: true, relayAllAgentMessages: false }, + }]); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByRole('button', { name: 'Attention' })).toBeDisabled(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Mirror' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { config: { relayAllAgentMessages: true } }, + expect.anything(), + )); + }); + + it('uses the add drawer with every pod the user is in, and installs through the lifecycle verb', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } }); + renderPage(); + + fireEvent.click(await findTile('Channel')); + const drawer = await findDrawer('Connect a channel'); + const picker = within(drawer).getByLabelText('Pod to bridge'); + const options = Array.from(picker.querySelectorAll('option')).map((option) => option.textContent); + expect(options).toContain('Rewire Live Demo'); + expect(options).toContain('Town Square'); + expect(picker.closest('.v2-cx-drawer')).not.toBeNull(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Connect' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/telegram/install', + { podId: 'p1' }, + expect.anything(), + )); + }); + + it('selects Slack in the add drawer and installs through its lifecycle verb', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { integration: { _id: 'slack-new' } } }); + renderPage(); + + fireEvent.click(await findTile('Channel')); + const drawer = await findDrawer('Connect a channel'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Slack' })); + fireEvent.click(within(drawer).getByRole('button', { name: 'Connect' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/install', + { podId: 'p1' }, + expect.anything(), + )); + }); + + // Each install refusal keeps the form where it is, with the sentence at the page. + const openAddAndConnect = async () => { + fireEvent.click(await findTile('Channel')); + const drawer = await findDrawer('Connect a channel'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Connect' })); + return drawer; + }; + + it('keeps the form open while an install claim is still in progress', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { status: 'installing' } }); + renderPage(); + + const drawer = await openAddAndConnect(); + expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument(); + expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument(); + }); + + it('names the pending pod returned with an install response', async () => { + mockGets([]); + axios.post.mockResolvedValue({ data: { status: 'installing', boundPodId: 'p1' } }); + renderPage(); + + const drawer = await openAddAndConnect(); + expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument(); + expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument(); + }); + + it('keeps the form open when the server reports an install lock', async () => { + mockGets([]); + axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress', boundPodId: 'p1' } } }); + renderPage(); + + const drawer = await openAddAndConnect(); + expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument(); + expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument(); + }); + + it('keeps the form open when an install lock does not name its pod', async () => { + mockGets([]); + axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress' } } }); + renderPage(); + + const drawer = await openAddAndConnect(); + expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument(); + expect(within(drawer).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(); + + const drawer = await openAddAndConnect(); + const refusal = await screen.findByText(/bound to Rewire Live Demo/); + expect(refusal).toBeInTheDocument(); + // TASK-154 item 6: the old copy ('Remove it to bind a different pod') taught the + // one-pod model. The refusal is about the bind, and other pods reach the channel + // through the switches. TASK-173: it names the label the user can actually look + // for, and the phrase is longer than the section title so a page that merely + // renders the section cannot satisfy it. + expect(screen.getByText(/through "Pods that reach this channel"/)).toBeInTheDocument(); + expect(screen.queryByText(/gate switches/)).toBeNull(); + // "Channel details" is the panel's `aria-label` and never visible text, so + // naming it in the copy sends a sighted user after a string the page does not + // render. + expect(refusal.textContent).not.toMatch(/Channel details/); + expect(screen.queryByText(/Remove it to bind/)).toBeNull(); + expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument(); + }); + + it('disconnects installable and legacy channels through their respective verbs', async () => { + mockGets([connectors[1]]); + axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); + const firstRender = renderPage(); + + let drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' })); + fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith( + '/api/installables/slack/install', + expect.anything(), + )); + + firstRender.unmount(); + mockGets([{ ...connectors[1], installationId: undefined }]); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' })); + fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { isActive: false }, + expect.anything(), + )); + }); + + it('disconnects an installable Telegram connector through its lifecycle verb', async () => { + mockGets([{ ...connectors[1], installationId: 'install-telegram-u1', type: 'telegram' }]); + axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); + renderPage(); + + const drawer = await openDrawer('Telegram'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' })); + fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith( + '/api/installables/telegram/install', + expect.anything(), + )); + }); + + it('does not offer another add control once both supported providers are present', async () => { + mockGets(); + renderPage(); + + await findTile('Telegram'); + expect(tileNode('Channel')).toBeNull(); + }); + + it('requests the credentialed Slack authorization URL and drops the opener', 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(); + + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/authorize-url', + {}, + expect.objectContaining({ withCredentials: true }), + )); + expect(slackWindow.location.assign).toHaveBeenCalledWith('https://slack.test/oauth?state=secret'); + expect(slackWindow.opener).toBeNull(); + }); + + const slackAuthorize = () => ({ + _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending', + config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }); + + it('keeps an un-coded Slack authorization failure generic — the fallback (Row C control)', async () => { + mockGets([slackAuthorize()]); + axios.post.mockRejectedValue({ response: { status: 503 } }); + renderPage(); + + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/authorize-url', + {}, + expect.objectContaining({ withCredentials: true }), + )); + // A refusal with no body still gets the generic sentence — the fallback the + // named codes must not have replaced. + const shown = await screen.findAllByText('Could not begin Slack authorization. Try again in a moment.'); + expect(shown.length).toBeGreaterThan(0); + }); + + it('names a refused Slack authorize in the drawer that asked, not at the page foot (Row C)', async () => { + mockGets([slackAuthorize()]); + axios.post.mockRejectedValue({ + response: { status: 409, data: { code: 'slack_already_authorized', error: 'Upstream wording that a known code must not surface.' } }, + }); + renderPage(); + + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' })); + await waitFor(() => expect(axios.post).toHaveBeenCalled()); + + // The code's own copy, not the server sentence the same body also carries. + expect(await within(drawer).findByRole('alert')).toHaveTextContent('Slack is already awaiting confirmation or connected.'); + expect(within(drawer).queryByText(/Upstream wording/)).toBeNull(); + // Placement is the defect (Row C): the page-level slot used to be the only one. + expect(pageError()).toBeNull(); + }); + + it('uses the server sentence when the refusal code is not one the page knows (Row C)', async () => { + mockGets([slackAuthorize()]); + axios.post.mockRejectedValue({ + response: { status: 503, data: { code: 'slack_upstream_unavailable', error: 'Slack refused the handshake.' } }, + }); + renderPage(); + + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' })); + await waitFor(() => expect(within(drawer).getByRole('alert')).toHaveTextContent('Slack refused the handshake.')); + }); + + it('names a failed Slack confirm in the drawer, not in the page slot (Row C)', async () => { + mockGets([slackPending(pendingBind)]); + axios.post.mockRejectedValue({ + response: { status: 409, data: { code: 'slack_bind_expired', error: 'Upstream wording that a known code must not surface.' } }, + }); + renderPage(); + + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument(); + expect(within(drawer).getByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeInTheDocument(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Confirm connection' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/installables/slack/confirm', {}, expect.anything())); + + expect(await within(drawer).findByRole('alert')).toHaveTextContent('Slack authorization expired. Start again.'); + expect(pageError()).toBeNull(); + }); + + it('confirms a Slack connection from the drawer', async () => { + mockGets([slackPending(pendingBind)]); + axios.post.mockResolvedValue({ data: { status: 'connected' } }); + renderPage(); + + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Confirm connection' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/confirm', + {}, + expect.anything(), + )); + }); + + it('requires a pod agent at Slack bind and sends the selected lead', async () => { + mockGets([slackPending(pendingBind)], undefined, [{ + _id: 'p1', name: 'Rewire Live Demo', type: 'chat', + members: [{ _id: 'agent-1', username: 'kai', isBot: true }], + }]); + axios.post.mockResolvedValue({ data: { status: 'connected' } }); + renderPage(); + + const drawer = await openDrawer('Slack'); + const confirm = within(drawer).getByRole('button', { name: 'Confirm connection' }); + expect(confirm).toBeDisabled(); + fireEvent.click(confirm); + expect(axios.post).not.toHaveBeenCalledWith('/api/installables/slack/confirm', expect.anything(), expect.anything()); + fireEvent.change(within(drawer).getByLabelText('Agent for plain messages'), { target: { value: 'kai' } }); + expect(confirm).toBeEnabled(); + fireEvent.click(confirm); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/confirm', { leadAgentUsername: 'kai' }, expect.anything(), + )); + }); + + it('does not treat a failed pod read as an empty pod at Slack confirmation', async () => { + mockGets([slackPending(pendingBind)], undefined, new Error('pods unavailable')); + renderPage(); + + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument(); + expect(within(drawer).queryByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeNull(); + expect(within(drawer).getByRole('button', { name: 'Confirm connection' })).toBeDisabled(); + }); + + it('keeps rejection reachable from the Slack drawer', async () => { + mockGets([slackPending(pendingBind)]); + axios.post.mockResolvedValue({ data: { status: 'rejected' } }); + renderPage(); + + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('button', { name: 'This is not me' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/reject', + {}, + expect.anything(), + )); + }); + + it('consumes an opaque Slack callback result without rendering its 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(''); + expect(screen.queryByText('invalid_state')).toBeNull(); + }); + + it('shows a fixed unrequested-scope notice and consumes hostile callback query values', async () => { + window.history.replaceState( + {}, + '', + '/v2/connectors?hostedMcp=error&code=unrequested_scope&extraScope=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E&extraScope=spoofed', + ); + mockGets([]); + renderPage(); + + expect(await screen.findByRole('alert')).toHaveTextContent( + "The provider granted access Commonly didn’t ask for, so the connection wasn’t saved.", + ); + expect(window.location.search).toBe(''); + expect(screen.queryByText(/img|spoofed/i)).toBeNull(); + expect(screen.queryByText('unrequested_scope')).toBeNull(); + }); + + it.each([ + ['unknown_entry', 'hostedMcpUnknownEntry'], + ['invalid_state', 'hostedMcpInvalidState'], + ['state_expired', 'hostedMcpInvalidState'], + ['state_consumed', 'hostedMcpInvalidState'], + ['browser_mismatch', 'hostedMcpInvalidState'], + ['issuer_mismatch', 'hostedMcpInvalidState'], + ['consent_denied', 'hostedMcpConsentDenied'], + ['vendor_error', 'hostedMcpVendorError'], + ['rate_limited', 'hostedMcpRateLimited'], + ['issuer_unreachable', 'hostedMcpIssuerUnavailable'], + ['issuer_metadata_incomplete', 'hostedMcpIssuerUnavailable'], + ['client_not_configured', 'hostedMcpClientSetup'], + ['client_registration_required', 'hostedMcpClientSetup'], + ['client_registration_unsupported', 'hostedMcpClientSetup'], + ['client_registration_refused', 'hostedMcpClientSetup'], + ['client_auth_unsupported', 'hostedMcpClientSetup'], + ['client_auth_refused', 'hostedMcpClientSetup'], + ['authorization_parameter_conflict', 'hostedMcpClientSetup'], + ['exchange_refused', 'hostedMcpExchangeRefused'], + ['exchange_unreachable', 'hostedMcpExchangeUnreachable'], + ['exchange_incomplete', 'hostedMcpExchangeIncomplete'], + ['exchange_invalid_scope', 'hostedMcpExchangeIncomplete'], + ['unrequested_scope', 'hostedMcpUnrequestedScope'], + ['authentication_required', 'hostedMcpAuthenticationRequired'], + ])('shows a fixed notice for hosted-MCP code %s and consumes the query', async (code, noticeKey) => { + window.history.replaceState({}, '', `/v2/connectors?hostedMcp=error&code=${code}&error_description=spoofed`); + mockGets([]); + renderPage(); + + expect(await screen.findByRole('alert')).toHaveTextContent(en.connectors[noticeKey]); + expect(window.location.search).toBe(''); + expect(screen.queryByText(/spoofed/)).toBeNull(); + }); + + 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(''); + }); + + // The manual-revoke notice rides the tool's reveal (its popover while nothing is + // granted), so the person opens the Linear tile to read it. + const connectedHosted = (id, entryId, revokePage) => ([{ + _id: id, type: 'hosted-mcp', status: 'connected', config: { entryId, revokePage }, + }]); + const hostedEntry = (id, installableId, label) => ({ + ...hostedLinearCatalogEntry(id), installableId, label, entryId: installableId, + }); + const callbackUrl = (entryId, revokePage) => ( + `/v2/connectors?hostedMcp=connected&entryId=${entryId}&revokeAt=${encodeURIComponent(revokePage)}` + ); + + it('shows the server-owned provider revoke page after a hosted-MCP reconnect needs manual withdrawal', async () => { + const revokePage = 'https://linear.app/settings/security'; + window.history.replaceState({}, '', callbackUrl('linear', revokePage)); + mockGets(connectedHosted('i-linear', 'linear', revokePage), [hostedLinearCatalogEntry()]); + renderPage(); + + const popover = await openPopover('Linear'); + const reviewLink = await within(popover).findByRole('link', { name: "Open Linear's connected apps" }); + expect(reviewLink).toHaveAttribute('href', revokePage); + expect(reviewLink).toHaveAttribute('target', '_blank'); + expect(reviewLink).toHaveAttribute('rel', 'noopener noreferrer'); + const notice = within(popover).getByRole('status'); + expect(notice).toHaveTextContent( + 'Your previous Linear account may still have an MCP grant connected. Switch to it in Linear, then revoke the MCP grant under Linear\'s connected apps.', + ); + expect(window.location.search).toBe(''); + }); + + it('the manual-revoke notice tells the person to switch to the account they connected before', async () => { + const revokePage = 'https://linear.app/settings/security'; + window.history.replaceState({}, '', callbackUrl('linear', revokePage)); + mockGets(connectedHosted('i-linear', 'linear', revokePage), [hostedLinearCatalogEntry()]); + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + renderPage(); + + const popover = await openPopover('Linear'); + const reviewLink = await within(popover).findByRole('link', { name: '打开 Linear 的已连接应用' }); + expect(reviewLink).toHaveAttribute('href', revokePage); + expect(within(popover).getByRole('status')).toHaveTextContent( + '你之前的 Linear 账号可能仍连着一个 MCP 授权。请在 Linear 切换到那个账号,再在其已连接应用中撤销该 MCP 授权。', + ); + expect(window.location.search).toBe(''); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + it('warns that removing Supabase authorization disconnects everyone in the organization', async () => { + const revokePage = 'https://supabase.com/dashboard/org/_/apps'; + window.history.replaceState({}, '', callbackUrl('supabase', revokePage)); + mockGets(connectedHosted('i-supabase', 'supabase', revokePage), [hostedEntry('i-supabase', 'supabase', 'Supabase')]); + renderPage(); + + const popover = await openPopover('Supabase'); + const reviewLink = await within(popover).findByRole('link', { name: 'Open Supabase Authorized apps' }); + expect(reviewLink).toHaveAttribute('href', revokePage); + expect(within(popover).getByRole('status')).toHaveTextContent( + 'Removing Commonly under Authorized apps disconnects everyone who connected that organization.', + ); + expect(within(popover).getByRole('status')).not.toHaveTextContent('remove Commonly there'); + }); + + it('warns that revoking the Sentry MCP app ends every client that server holds, and does not name Commonly', async () => { + const revokePage = 'https://sentry.io/settings/account/api/authorizations/'; + window.history.replaceState({}, '', callbackUrl('sentry', revokePage)); + mockGets(connectedHosted('i-sentry', 'sentry', revokePage), [hostedEntry('i-sentry', 'sentry', 'Sentry')]); + renderPage(); + + const popover = await openPopover('Sentry'); + const reviewLink = await within(popover).findByRole('link', { name: "Open Sentry's connected apps" }); + expect(reviewLink).toHaveAttribute('href', revokePage); + expect(within(popover).getByRole('status')).toHaveTextContent( + 'Revoking it ends every client that server holds for the account, not only Commonly.', + ); + expect(within(popover).getByRole('status')).not.toHaveTextContent('remove Commonly there'); + }); + + it('keeps the generic manual-revoke copy for an entry without its own', async () => { + const revokePage = 'https://airtable.com/account/security'; + window.history.replaceState({}, '', callbackUrl('airtable', revokePage)); + mockGets(connectedHosted('i-airtable', 'airtable', revokePage), [hostedEntry('i-airtable', 'airtable', 'Airtable')]); + renderPage(); + + const popover = await openPopover('Airtable'); + expect(await within(popover).findByRole('link', { name: "Open Airtable's connected apps" })).toHaveAttribute('href', revokePage); + expect(within(popover).getByRole('status')).toHaveTextContent( + 'Your previous Airtable account may still have Commonly connected. Switch to it on Airtable, then remove Commonly there.', + ); + }); + + it('does not turn a callback query into a provider link unless it matches the connection', async () => { + window.history.replaceState( + {}, + '', + '/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=https%3A%2F%2Fevil.example%2F', + ); + mockGets(connectedHosted('i-linear', 'linear', 'https://linear.app/settings/security'), [hostedLinearCatalogEntry()]); + renderPage(); + + const popover = await openPopover('Linear'); + expect(within(popover).getByText('Read Linear issues and projects.')).toBeInTheDocument(); + await waitFor(() => expect(window.location.search).toBe('')); + expect(screen.queryByRole('link', { name: "Open Linear's connected apps" })).toBeNull(); + }); + + it('shows the error reconnect action and retains the separate removal action', async () => { + mockGets([{ + _id: 'i-slack-error', installationId: 'install-slack-u1', type: 'slack', status: 'error', + config: { chatTitle: 'Commonly HQ', liveRelay: true }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + axios.post.mockRejectedValue({ response: { status: 503 } }); + renderPage(); + + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByText('The connection dropped.')).toBeInTheDocument(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Authorize in Slack' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/slack/authorize-url', + {}, + expect.objectContaining({ withCredentials: true }), + )); + fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' })); + expect(within(drawer).getByRole('button', { name: /Really remove/ })).toBeInTheDocument(); + }); + + it('names the reason a connector needs attention when the server sent one', async () => { + // The sentence is the only place a person sees why relaying stopped. A classified + // permanent failure writes `errorMessage`; the generic sentence is the fallback + // for everything else, not a replacement for it. + const reason = 'Telegram stopped delivering: the bot was blocked or removed from this chat.'; + mockGets([{ + _id: 'i-tg-error', installationId: 'install-tg-u1', type: 'telegram', status: 'error', errorMessage: reason, + // The flag is what makes the message readable to a person: it is set by + // connectorDeliveryFailureService, the writer that owns the pair. + errorMessageUserFacing: true, + config: { chatTitle: 'Ops chat', liveRelay: true }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + renderPage(); + + await findTile('Telegram'); + expect(dot(reason)).toBeInTheDocument(); + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText(reason)).toBeInTheDocument(); + expect(screen.queryByText('The connection dropped.')).toBeNull(); + }); + + it('does not print a provider error text for a channel, only a message written for a person', async () => { + // `Integration.errorMessage` has an older writer: externalFeedService copies a + // provider's response, or a raw exception message, into it for the `x` and + // `instagram` rows. Those rows reach this same error branch, so a value in the + // field is not permission to render it — `errorMessageUserFacing` is, and it is + // absent here. The drawer still shows the generic sentence. + const raw = 'connect ECONNREFUSED 10.4.4.7:443'; + mockGets([{ + _id: 'i-x-error', installationId: 'install-x-u1', type: 'x', status: 'error', errorMessage: raw, + config: { chatTitle: 'Feed' }, + podId: { _id: 'p1', name: 'Rewire Live Demo' }, + }]); + renderPage(); + + const drawer = await openDrawer('X'); + expect(within(drawer).getByText('The connection dropped.')).toBeInTheDocument(); + expect(screen.queryByText(raw)).toBeNull(); + expect(screen.queryByRole('img', { name: raw })).toBeNull(); + }); + + it('derives the installable lifecycle target from the connector type', () => { + expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install'); + }); + + // D8 Phase 2: tiles keyed by the capability catalog (D1/D2), the panel's gate + // list (D4), and the not-linked state (#1551). + describe('catalog tiles', () => { + const entry = (over = {}) => ({ + installableId: 'telegram', + label: 'Telegram', + // Mirror of the provider manifest's copy, which the server sends as the + // entry's description. The source pin is backend manifestReadiness. + description: 'Link your Telegram chat to Commonly — every pod you turn on reaches it.', + available: true, + installation: null, + integration: null, + ...over, + }); + const liveIntegration = (over = {}) => ({ + _id: 'i-live', + installationId: 'install-slack-u1', + type: 'slack', + status: 'connected', + scope: 'user', + isActive: true, + createdAt: new Date(Date.now() - 3_600_000).toISOString(), + updatedAt: new Date().toISOString(), + config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' } } }, + podId: 'p1', + ...over, + }); + const slackEntry = (over = {}) => entry({ + installableId: 'slack', + label: 'Slack', + installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, + integration: liveIntegration(), + ...over, + }); + const mockCatalog = (installables, list = [], pods = [ + { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [{ _id: 'b1', username: 'vale', isBot: true }] }, + { _id: 'p2', name: 'Ops', type: 'team', members: [] }, + ]) => mockApi({ connectors: list, installables, pods }); + + // TASK-155. The walk found that adding a channel with a live channel open left + // the panel on that channel: the form rendered beside it instead, so the picker + // appeared somewhere other than the panel the page uses for the thing you are doing. + it('TASK-155: adding a channel moves the drawer to the picker', async () => { + mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]); + renderPage(); + const slack = await openDrawer('Slack'); + expect(within(slack).getByText('What the channel sees')).toBeInTheDocument(); + + const popover = await openPopover('Telegram'); + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + + const form = await findDrawer('Connect a channel'); + expect(within(form).getByLabelText('Pod to bridge')).toBeInTheDocument(); + expect(screen.queryByText('What the channel sees')).toBeNull(); + // One form, not two. + expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1); + }); + + it('TASK-155: the add tile moves the drawer while a channel is open', async () => { + mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]); + renderPage(); + await openDrawer('Slack'); + + fireEvent.click(await findTile('Channel')); + + const form = await findDrawer('Connect a channel'); + expect(within(form).getByLabelText('Pod to bridge')).toBeInTheDocument(); + expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1); + expect(screen.queryByText('What the channel sees')).toBeNull(); + }); + + it('TASK-155: opening a channel while the form is open closes the form', async () => { + mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]); + renderPage(); + fireEvent.click(await findTile('Channel')); + expect(within(await findDrawer('Connect a channel')).getByLabelText('Pod to bridge')).toBeInTheDocument(); + + fireEvent.click(await findTile('Slack')); + + // The form wins the drawer while it is open, so the tile click has to close + // it — otherwise this is the walk's symptom pointed the other way + // (connector-ops 74623 item 1). + expect(within(await findDrawer('Slack')).getByText('What the channel sees')).toBeInTheDocument(); + expect(screen.queryByLabelText('Pod to bridge')).toBeNull(); + }); + + it('TASK-155: the picker takes focus when the form opens', async () => { + mockCatalog([entry()]); + renderPage(); + + fireEvent.click(await findTile('Channel')); + + // At 390 the panel is below the fold of the page's scroller; focus is what + // scrolls the picker into view (connector-ops 74623 item 2). + expect(await screen.findByLabelText('Pod to bridge')).toHaveFocus(); + }); + + it('TASK-155: the picker defaults to a room, never a personal pod', async () => { + mockCatalog([entry()], [], [ + { _id: 'p-scout', name: 'Scout (Default)', type: 'agent-room', members: [] }, + { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }, + { _id: 'p2', name: 'Ops', type: 'team', members: [] }, + ]); + renderPage(); + + fireEvent.click(await findTile('Channel')); + + const picker = (await screen.findByLabelText('Pod to bridge')) as HTMLSelectElement; + expect(picker.value).toBe('p1'); + // A DM stays selectable: it is a legal target, just never the default. + expect(Array.from(picker.options).map((option) => option.textContent)).toContain('Scout (Default)'); + }); + + it('counts connected channels in the page head, not catalogue options', async () => { + mockCatalog([entry(), slackEntry({ installation: { status: 'active', boundPodId: 'p1' } })]); + renderPage(); + + await findTile('Slack'); + expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('1 on · 1 available'); + }); + + it('counts nothing as on when the catalogue has no live channels', async () => { + mockCatalog([entry(), entry({ installableId: 'discord', label: 'Discord' })]); + renderPage(); + + await findTile('Telegram'); + expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('0 on · 2 available'); + }); + + it('TASK-131: a catalog failure prints the generic sentence, not the raw exception that wrote the row', async () => { + // The catalogue half reads `InstallableInstallation.errorMessage`, whose + // projection-failure writer stores `error.message` verbatim. Same rule as the + // pod-scoped half: a value in the field is not permission to render it, and + // the flag is absent on this fixture. + const raw = 'connect ECONNREFUSED 10.4.4.7:443'; + mockCatalog([entry({ installation: { status: 'error', errorMessage: raw } })]); + renderPage(); + + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText('Setup didn’t finish.')).toBeInTheDocument(); + expect(screen.queryByText(raw)).toBeNull(); + }); + + it('TASK-131: a catalog message written for a person still renders verbatim', async () => { + // The reconciler's own reasons are written for the person reading this tile, + // and the flag the builder sets beside them is what keeps them readable once + // the generic sentence became the fallback. + mockCatalog([entry({ installation: { status: 'error', errorMessage: REASON_CHANNEL_GONE, errorMessageUserFacing: true } })]); + renderPage(); + + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText(REASON_CHANNEL_GONE)).toBeInTheDocument(); + expect(screen.queryByText('Setup didn’t finish.')).toBeNull(); + }); + + // Tools plan (option A, two lists): a tool Installable shares the catalogue + // response but belongs to the Tools group, never to Channels. + it('a tool Installable in the catalogue never renders as a channel tile', async () => { + mockCatalog([ + { + installableId: 'github', + list: 'tools', + label: 'GitHub', + description: 'Issues and pull requests.', + available: true, + broker: { id: 'commonly-grant-broker' }, + tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }], + connections: [], + installation: null, + integration: null, + }, + entry({ list: 'channels' }), + ]); + renderPage(); + + await findTile('Telegram'); + const channels = screen.getByRole('region', { name: 'Channels' }); + const tools = screen.getByRole('region', { name: 'Tools' }); + expect(channels.querySelector('button.v2-cx-tile[title="GitHub"]')).toBeNull(); + expect(tools.querySelector('button.v2-cx-tile[title="GitHub"]')).not.toBeNull(); + expect(channels.querySelector('button.v2-cx-tile[title="Telegram"]')).not.toBeNull(); + expect(tools.querySelector('button.v2-cx-tile[title="Telegram"]')).toBeNull(); + }); + + it('keeps tool-only connections out of Channels and refuses an unknown type as a tile title', async () => { + mockCatalog([entry()], [ + { + // Real hosted-MCP rows have no installationId; filter them by type. + _id: 'i-hosted-linear', type: 'hosted-mcp', status: 'connected', + config: { entryId: 'linear' }, + }, + { + _id: 'i-github-app', installationId: 'github-install', type: 'github-app', status: 'connected', + }, + { + _id: 'i-unknown-channel', type: 'custom-messaging-platform', status: 'connected', + }, + ]); + renderPage(); + + await findTile('Telegram'); + const channels = screen.getByRole('region', { name: 'Channels' }); + const titles = Array.from(channels.querySelectorAll('button.v2-cx-tile')).map((tile) => tile.getAttribute('title')); + // Telegram, and the add tile for the one provider still to connect. No tile + // for a hosted tool, the GitHub App or an unmapped type. + expect(titles).toEqual(['Telegram', 'Channel']); + for (const text of ['hosted-mcp', 'github-app', 'Hosted tool', 'GitHub App', 'custom-messaging-platform', 'Custom Messaging Platform']) { + expect(screen.queryByText(text)).toBeNull(); + expect(screen.queryByTitle(text)).toBeNull(); + } + }); + + // TASK-024. Discord is a shipping connector (routes/discord.ts: install link, + // callback, binding, uninstall) that read as "we don't build this" because its + // manifest declared no readiness(), which is what the catalog filters on. Once + // it declares one the catalog owns every claim about it: configured -> a + // connectable tile, not configured -> the not-enabled tile that already exists + // for slack. + const glyphPath = (type: string): string | null => { + const { container } = renderPlatformGlyph(type); + return container.querySelector('svg path')?.getAttribute('d') || null; + }; + const renderPlatformGlyph = (type: string) => { + // eslint-disable-next-line global-require + const { render } = require('@testing-library/react'); + return render(); + }; + + it('TASK-338: not-offered channels fold under "+N off", below the live tiles, collapsed', async () => { + mockCatalog([ + entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }), + entry({ + installableId: 'telegram', + label: 'Telegram', + available: true, + installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, + integration: liveIntegration({ _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }), + }), + ]); + renderPage(); + await findTile('Telegram'); + const channels = screen.getByRole('region', { name: 'Channels' }); + const off = within(channels).getByRole('button', { name: '+1 off' }); + expect(off).toHaveAttribute('aria-expanded', 'false'); + expect(tileNode('Slack')).toBeNull(); + expect(channels.querySelector('button.v2-cx-tile')).toHaveAttribute('title', 'Telegram'); + fireEvent.click(off); + expect(off).toHaveAttribute('aria-expanded', 'true'); + expect(tileNode('Slack')).toHaveClass('v2-cx-tile--dim'); + }); + + it('renders the zh-CN catalog copy for a channel and the server text for an unmapped one', async () => { + mockCatalog([ + entry({ list: 'channels' }), + entry({ installableId: 'acme-chat', label: 'Acme Chat', description: 'Acme chat server copy.' }), + ]); + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + renderPage(); + const telegram = await openPopover('Telegram'); + expect(within(telegram).getByText('把你的 Telegram 聊天接入 Commonly,你开启的每个 Pod 都能把消息发到这里。')).toBeInTheDocument(); + // `Acme Chat` has no mapped label, so it is not a Channels tile; the unmapped + // description is the server's own and is not asserted through a tile here. + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + it('describes Discord only through the catalog, never as a provider we have not built', async () => { + mockCatalog([ + entry({ installableId: 'discord', label: 'Discord', available: false, unavailableReason: 'not_configured' }), + ]); + renderPage(); + + await pageReady(); + await expandOff('Channels'); + const tile = await findTile('Discord'); + expect(tile).toHaveClass('v2-cx-tile--dim'); + expect(tile.textContent).toContain('Discord'); + expect(tile.querySelector('svg')).not.toBeNull(); + const popover = await openPopover('Discord'); + expect(within(popover).getByText('ask your operator')).toBeInTheDocument(); + expect(screen.queryByText(/Tell us which channel/)).toBeNull(); + }); + + it('offers a rostered provider with Add, and an unmapped one inherits no onboarding sentence', async () => { + // Slack's line is in the map; Discord's is not. The point of the map is that + // Discord renders NO sentence rather than Slack's. + mockCatalog([ + entry({ installableId: 'discord', label: 'Discord', available: true, offered: true }), + entry({ installableId: 'slack', label: 'Slack', available: true, offered: true }), + ]); + renderPage(); + + const discord = await openPopover('Discord'); + expect(discord.textContent).not.toContain('one click in your workspace'); + expect(discord.textContent).not.toContain('one message'); + expect(within(discord).getByRole('button', { name: 'Add' })).toBeInTheDocument(); + + const slack = await openPopover('Slack'); + expect(slack.textContent).toContain('one click in your workspace'); + + expect(screen.queryByText(/Tell us which channel/)).toBeNull(); + }); + + it('does not list a provider this instance does not offer (D215)', async () => { + // Discord on commonly.me: usable keys, no builtin Installable row, never + // walked. A stranger must not see it, not even behind "+N off". + mockCatalog([ + entry(), + entry({ installableId: 'discord', label: 'Discord', available: true, offered: false }), + ]); + renderPage(); + + await findTile('Telegram'); + // The off button only exists when a dead tile does, so its absence is the + // assertion; a collapsed group would hide a listed Discord from the query. + expect(screen.queryByRole('button', { name: /^\+\d+ off$/ })).toBeNull(); + expect(tileNode('Discord')).toBeNull(); + }); + + it('keeps an installed but no-longer-offered provider as not connectable, with no Add anywhere', async () => { + // The ruled middle state, reachable only by someone who installed it before + // the roster row went away: it states the state and offers no action. Before + // #1826 the row rendered an Add whose click ended in 404 installable_not_found. + mockCatalog([ + entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, installation: { status: 'active', boundPodId: 'p1' } }), + ]); + renderPage(); + + await pageReady(); + await expandOff('Channels'); + const tile = await findTile('Discord'); + expect(tile).toHaveClass('v2-cx-tile--dim'); + expect(tile.querySelector('svg')).not.toBeNull(); + fireEvent.click(tile); + const drawer = await findDrawer('Discord'); + expect(within(drawer).getByText('Unavailable here.')).toBeInTheDocument(); + // No Add and no connect verb: an offered provider is the only thing that may + // produce them, and there is none in this catalog. + expect(within(drawer).queryByRole('button', { name: /^(Add|Connect|Retry)$/ })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); + expect(tileNode('Channel')).toBeNull(); + expect(screen.queryByText(/Tell us which channel/)).toBeNull(); + }); + + it('keeps a no-longer-offered provider listed when the person has only an integration row', async () => { + // The third arm of the D215 filter: a channel connected before the roster row + // went away can leave an integration with no installation. Dropping it from + // the list would hide a live connection from its owner. + mockCatalog([ + entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, integration: liveIntegration() }), + ]); + renderPage(); + + await pageReady(); + await expandOff('Channels'); + expect(await findTile('Discord')).toHaveClass('v2-cx-tile--dim'); + }); + + it('renders an unavailable provider with its blocker and an available one with Add', async () => { + mockCatalog([ + entry(), + entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }), + ]); + axios.post.mockResolvedValue({ data: { status: 'installing' } }); + renderPage(); + + await pageReady(); + await expandOff('Channels'); + const slack = await openPopover('Slack'); + // Each unconnectable provider gets its own state line; an offered provider is + // the only thing that may draw Add. + expect(within(slack).getByText('ask your operator')).toBeInTheDocument(); + expect(within(slack).getByText('Link your Telegram chat to Commonly — every pod you turn on reaches it.')).toBeInTheDocument(); + expect(screen.queryByText('not_configured')).toBeNull(); + const ask = within(slack).getByRole('link', { name: 'Ask' }); + expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request'); + expect(ask).toHaveClass('v2-connector-row__action--secondary'); + expect(within(slack).queryByRole('button', { name: 'Add' })).toBeNull(); + + const telegram = await openPopover('Telegram'); + const choosePod = within(telegram).getAllByRole('button', { name: 'Add' }); + expect(choosePod).toHaveLength(1); + fireEvent.click(choosePod[0]); + const form = await findDrawer('Connect a channel'); + expect(within(form).getByRole('button', { name: 'Telegram' })).toHaveClass('v2-connectors__provider--selected'); + expect(within(form).queryByRole('button', { name: 'Slack' })).toBeNull(); + fireEvent.click(form.querySelector('.v2-connectors__create')); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/telegram/install', + { podId: 'p1' }, + expect.anything(), + )); + }); + + it('labels the popover step separately from the final connect action', async () => { + mockCatalog([entry()]); + renderPage(); + + const popover = await openPopover('Telegram'); + const choosePod = within(popover).getByRole('button', { name: 'Add' }); + expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); + + fireEvent.click(choosePod); + const form = await findDrawer('Connect a channel'); + expect(within(form).getByLabelText('Pod to bridge')).toBeInTheDocument(); + expect(within(form).getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + }); + + it('gives a zero-pod owner a path to create a pod before connecting', async () => { + mockCatalog([entry()], [], []); + renderWithRoutes( + + } /> + pods list} /> + , + ); + + fireEvent.click(await findTile('Channel')); + const drawer = await findDrawer('Connect a channel'); + expect(within(drawer).queryByLabelText('Pod to bridge')).toBeNull(); + expect(within(drawer).queryByRole('group', { name: 'Channel provider' })).toBeNull(); + expect(within(drawer).getByText('No pod yet. Create one, then come back to connect a channel.')).toBeInTheDocument(); + expect(screen.queryByText('Choose a channel and the pod it should join.')).toBeNull(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Create a pod' })); + expect(await screen.findByText('pods list')).toBeInTheDocument(); + }); + + it('keeps the existing picker visible while pod membership is still loading', async () => { + mockCatalog([entry()], [], 'pending'); + renderPage(); + + fireEvent.click(await findTile('Channel')); + const drawer = await findDrawer('Connect a channel'); + expect(within(drawer).getByRole('group', { name: 'Channel provider' })).toBeInTheDocument(); + expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument(); + expect(within(drawer).queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull(); + expect(within(drawer).queryByRole('button', { name: 'Create a pod' })).toBeNull(); + }); + + it('keeps the existing picker visible when pod membership cannot be read', async () => { + mockCatalog([entry()], [], new Error('pods unavailable')); + renderPage(); + + fireEvent.click(await findTile('Channel')); + const drawer = await findDrawer('Connect a channel'); + expect(within(drawer).getByRole('group', { name: 'Channel provider' })).toBeInTheDocument(); + expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument(); + expect(within(drawer).queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull(); + expect(within(drawer).queryByRole('button', { name: 'Create a pod' })).toBeNull(); + }); + + it('shows Setting up… without a control while the claim is fresh, and Cancel once it is stale', async () => { + mockCatalog([entry({ installation: { status: 'installing', claimedAt: new Date().toISOString(), components: [] } })]); + const fresh = renderPage(); + let drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText('Setting up…')).toBeInTheDocument(); + expect(within(drawer).queryByRole('button', { name: 'Cancel' })).toBeNull(); + fresh.unmount(); + + mockCatalog([entry({ + installation: { status: 'installing', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] }, + })]); + axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } }); + renderPage(); + drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText('Setup is taking longer than it should.')).toBeInTheDocument(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything())); + }); + + it('offers Retry on an error parent, posting the bound pod, and Remove in the drawer', async () => { + mockCatalog([entry({ + // The reconciler's own reason, and since TASK-131 it travels with the flag + // that says so: the line renders a message only when a writer declared it + // was written for a person. + installation: { + status: 'error', + errorMessage: REASON_CHANNEL_GONE, + errorMessageUserFacing: true, + boundPodId: 'p2', + updatedAt: new Date().toISOString(), + components: [], + }, + })]); + axios.post.mockResolvedValue({ data: { status: 'installing' } }); + axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); + renderPage(); + + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText(REASON_CHANNEL_GONE)).toBeInTheDocument(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/installables/telegram/install', + { podId: 'p2' }, + expect.anything(), + )); + fireEvent.click(within(drawer).getByRole('button', { name: 'Remove' })); + fireEvent.click(within(drawer).getByRole('button', { name: /Really remove/ })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything())); + }); + + it('offers Retry remove once an uninstalling claim is stale', async () => { + mockCatalog([entry({ + installation: { status: 'uninstalling', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] }, + })]); + axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } }); + renderPage(); + + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText('Removal is taking longer than it should.')).toBeInTheDocument(); + fireEvent.click(within(drawer).getByRole('button', { name: 'Retry remove' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything())); + }); + + it('shows a paused parent with its reason and no owner control', async () => { + mockCatalog([slackEntry({ + installation: { status: 'paused', updatedAt: new Date().toISOString(), components: [] }, + integration: liveIntegration({ config: { chatTitle: 'Rewire crew', liveRelay: true, adminPause: { reason: 'Spam report under review.', at: new Date(Date.now() - 120_000).toISOString() } } }), + })]); + renderPage(); + + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByText('Paused by an administrator. Spam report under review.')).toBeInTheDocument(); + expect(within(drawer).getByText('Only an administrator can resume it. Your relay, mode and pods are kept.')).toBeInTheDocument(); + expect(within(drawer).queryByRole('button', { name: 'Remove' })).toBeNull(); + expect(within(drawer).queryByRole('button', { name: 'Retry' })).toBeNull(); + expect(within(drawer).queryByRole('checkbox', { name: 'Relay' })).toBeNull(); + }); + + it('treats a missing projection under an active parent as a Retry state', async () => { + mockCatalog([entry({ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, integration: null })]); + axios.post.mockResolvedValue({ data: { status: 'installing' } }); + renderPage(); + + const drawer = await openDrawer('Telegram'); + expect(within(drawer).getByText('The channel record is gone.')).toBeInTheDocument(); + expect(within(drawer).getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); + + it('renders the not-linked channel with its pod list open and Make active', async () => { + mockCatalog([slackEntry({ + integration: liveIntegration({ podId: null, config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} } }), + })]); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + + await findTile('Slack'); + expect(dot('Rewire crew · not linked to a pod')).toBeInTheDocument(); + const drawer = await openDrawer('Slack'); + // An unlinked channel has no active pod, so the list opens already expanded. + expect(within(drawer).getByText('Pick where your messages go')).toBeInTheDocument(); + expect(within(drawer).queryByText('active')).toBeNull(); + const makeActive = within(drawer).getAllByRole('button', { name: 'Make active' }); + expect(makeActive).toHaveLength(2); + fireEvent.click(makeActive[1]); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { podId: 'p2' }, + expect.anything(), + )); + }); + + // TASK-164: the gate line was the last raw English relative time on this page — + // `since ${relativeTime(...)}` built its own sentence and no key could reach it. + it('the gate line reads in zh-CN, not English (TASK-164)', async () => { + mockCatalog([slackEntry()]); + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + renderPage(); + await openDrawer('Slack'); + const line = await screen.findByText(/前起$/); + expect(line.textContent).toMatch(/^(刚刚|\d+(分钟|小时|天)前)起$/); + expect(screen.queryByText(/^since /)).toBeNull(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + it('lists every pod with its gate, marks the active pod, and writes the whole gates map on a switch', async () => { + mockCatalog([slackEntry()]); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + + await findTile('Slack'); + expect(dot('Rewire crew · linked to Rewire Live Demo')).toBeInTheDocument(); + const drawer = await openDrawer('Slack'); + expect(within(drawer).getByText('Pods that reach this channel')).toBeInTheDocument(); + // The anchors, measured rather than assumed: the panel that holds that + // section is reachable by its ACCESSIBLE NAME and never as text. A copy that + // names it sends a sighted user hunting, which is the defect TASK-173 was + // opened for. + expect(within(drawer).getByLabelText('Channel details')).toBeInTheDocument(); + expect(screen.queryAllByText(/^Channel details$/)).toHaveLength(0); + expect(within(drawer).getByText('active')).toBeInTheDocument(); + expect(within(drawer).getByText('off')).toBeInTheDocument(); + expect(within(drawer).getByText(/^since /)).toBeInTheDocument(); + fireEvent.click(within(drawer).getByRole('switch', { name: 'Relay Ops' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { config: { gates: { + p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, + p2: { enabled: true, since: expect.any(String) }, + } } }, + expect.anything(), + )); + }); + + it('drops a gate for a pod the owner has left before writing the map', async () => { + mockCatalog([slackEntry({ + integration: liveIntegration({ + config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { + p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, + pLeft: { enabled: true, since: '2026-08-01T00:00:00.000Z' }, + } }, + }), + })]); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + + const drawer = await openDrawer('Slack'); + fireEvent.click(within(drawer).getByRole('switch', { name: 'Relay Ops' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { config: { gates: { + p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, + p2: { enabled: true, since: expect.any(String) }, + } } }, + expect.anything(), + )); + }); + + it('keeps mode and lead overrides behind the pod name and makes another pod active', async () => { + mockCatalog([slackEntry()]); + axios.patch.mockResolvedValue({ data: {} }); + renderPage(); + + const drawer = await openDrawer('Slack'); + expect(within(drawer).queryByRole('button', { name: 'Make active' })).toBeNull(); + fireEvent.click(within(drawer).getByRole('button', { name: /Ops/ })); + const more = within(drawer.querySelector('.v2-connector-gate__more')); + fireEvent.click(more.getByRole('button', { name: 'Mirror' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { config: { gates: { + p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, + p2: { enabled: false, mode: 'mirror', since: expect.any(String) }, + } } }, + expect.anything(), + )); + const makeActive = within(drawer).getByRole('button', { name: 'Make active' }); + await waitFor(() => expect(makeActive).not.toBeDisabled()); + fireEvent.click(makeActive); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( + '/api/integrations/i-live', + { podId: 'p2' }, + expect.anything(), + )); + }); + + it('keys an installable tile by the catalog, not by its legacy listing', async () => { + mockCatalog([slackEntry()], [{ ...connectors[1] }]); + renderPage(); + + await findTile('Slack'); + expect(document.querySelectorAll('button.v2-cx-tile[title="Slack"]')).toHaveLength(1); + }); + }); +}); + +// The zero-pod case needs a route table around the page. +// eslint-disable-next-line react/display-name +const ConnectorsPageForRoutes = () => { + // eslint-disable-next-line global-require + const Page = require('../connectors/ConnectorsPage').default; + return ; +}; diff --git a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx new file mode 100644 index 0000000000..75be094fb6 --- /dev/null +++ b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx @@ -0,0 +1,330 @@ +// @ts-nocheck +// Connectors page, the surface itself (TASK-733): the logo tile grid, the filter and +// search, "+N off", the popover that reveals an unconnected tile, the drawer that +// manages a connected one, and the add tile. Behaviour of what is inside them lives +// in ConnectorsPage.channels / .tools. +import React from 'react'; +import { fireEvent, screen, waitFor, within } from '@testing-library/react'; +import { + axios, mockApi, renderPage, tileNode, findTile, clickTile, findDrawer, findPopover, expandOff, pageReady, +} from '../__testkit__/connectorsKit'; + +jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory()); + +const pods = [{ _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }]; +const channel = (installableId, label, over = {}) => ({ + installableId, label, description: `${label} copy.`, available: true, offered: true, installation: null, integration: null, ...over, +}); +const liveSlack = channel('slack', 'Slack', { + installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: 'p1', components: [] }, + integration: { + _id: 'i-live', installationId: 'install-slack-u1', type: 'slack', status: 'connected', scope: 'user', isActive: true, + createdAt: new Date(Date.now() - 3_600_000).toISOString(), updatedAt: new Date().toISOString(), + config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} }, podId: 'p1', + }, +}); +const tool = (installableId, label, over = {}) => ({ + installableId, list: 'tools', label, description: `${label} tool copy.`, available: true, + tools: [{ name: `${installableId}.read`, requiredWriteMode: 'read', irreversible: false }], + connections: [{ connectionId: `c-${installableId}`, owner: 'o', repo: 'r' }], installation: null, integration: null, ...over, +}); + +const mockCatalog = (installables, extra = {}) => mockApi({ installables, pods, ...extra }); +const grid = (group) => within(screen.getByRole('region', { name: group })).queryAllByRole('button') + .filter((node) => node.classList.contains('v2-cx-tile')); + +beforeEach(() => { + jest.clearAllMocks(); + window.history.replaceState({}, '', '/v2/connectors'); +}); + +describe('Connectors page: the tile surface', () => { + it('renders one logo tile per catalogue entry, grouped under Channels and Tools', async () => { + mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]); + renderPage(); + + await findTile('GitHub'); + expect(screen.getByRole('heading', { name: 'Connectors' })).toBeInTheDocument(); + const channelTitles = grid('Channels').map((node) => node.getAttribute('title')); + expect(channelTitles).toEqual(expect.arrayContaining(['Telegram', 'Slack'])); + expect(grid('Tools').map((node) => node.getAttribute('title'))).toEqual(['GitHub', 'Linear']); + // The logo carries the name: every tile holds an svg and its label, and no + // description is printed on the grid itself. + for (const node of [tileNode('Telegram'), tileNode('GitHub')]) { + expect(node.querySelector('svg')).not.toBeNull(); + expect(node.querySelector('.v2-cx-tile__name')).toHaveTextContent(node.getAttribute('title')); + } + expect(screen.queryByText('Telegram copy.')).toBeNull(); + expect(screen.queryByText('GitHub tool copy.')).toBeNull(); + // Counts: Slack is on; Telegram, GitHub, Linear are offered. + expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('1 on · 3 available'); + }); + + it('the filter segment hides a group, and All brings it back', async () => { + mockCatalog([channel('telegram', 'Telegram'), tool('github', 'GitHub')]); + renderPage(); + await findTile('GitHub'); + + const filter = screen.getByRole('group', { name: 'Show' }); + const all = within(filter).getByRole('button', { name: 'All' }); + expect(all).toHaveAttribute('aria-pressed', 'true'); + + fireEvent.click(within(filter).getByRole('button', { name: 'Tools' })); + expect(screen.queryByRole('region', { name: 'Channels' })).toBeNull(); + expect(screen.getByRole('region', { name: 'Tools' })).toBeInTheDocument(); + expect(within(filter).getByRole('button', { name: 'Tools' })).toHaveAttribute('aria-pressed', 'true'); + expect(tileNode('Telegram')).toBeNull(); + + fireEvent.click(within(filter).getByRole('button', { name: 'Channels' })); + expect(screen.queryByRole('region', { name: 'Tools' })).toBeNull(); + expect(tileNode('Telegram')).not.toBeNull(); + expect(tileNode('GitHub')).toBeNull(); + + fireEvent.click(all); + expect(screen.getByRole('region', { name: 'Channels' })).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Tools' })).toBeInTheDocument(); + }); + + it('search filters tiles in both groups, drops the add tile, and says when nothing matches', async () => { + mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]); + renderPage(); + await findTile('GitHub'); + expect(tileNode('Channel')).not.toBeNull(); + + const search = screen.getByRole('searchbox', { name: 'Search' }); + fireEvent.change(search, { target: { value: 'tele' } }); + expect(tileNode('Telegram')).not.toBeNull(); + for (const name of ['Slack', 'GitHub', 'Linear']) expect(tileNode(name)).toBeNull(); + // Searching is for finding what exists, so the add tile steps aside. + expect(tileNode('Channel')).toBeNull(); + + fireEvent.change(search, { target: { value: 'linear' } }); + expect(tileNode('Linear')).not.toBeNull(); + expect(tileNode('Telegram')).toBeNull(); + + fireEvent.change(search, { target: { value: 'zzz' } }); + expect(screen.getByText('Nothing matches.')).toBeInTheDocument(); + + fireEvent.change(search, { target: { value: '' } }); + expect(tileNode('Telegram')).not.toBeNull(); + expect(tileNode('Channel')).not.toBeNull(); + }); + + it('"+N off" expands the not-offered tiles, dimmed, and collapses them again', async () => { + mockCatalog([ + channel('telegram', 'Telegram'), + channel('discord', 'Discord', { available: false, unavailableReason: 'not_configured' }), + tool('github', 'GitHub'), + tool('sentry', 'Sentry', { available: false, connections: [] }), + ]); + renderPage(); + await findTile('GitHub'); + + // One off per group, collapsed: the dead tiles are not in the grid yet. + const offButtons = screen.getAllByRole('button', { name: '+1 off' }); + expect(offButtons).toHaveLength(2); + for (const button of offButtons) expect(button).toHaveAttribute('aria-expanded', 'false'); + expect(tileNode('Discord')).toBeNull(); + expect(tileNode('Sentry')).toBeNull(); + + await expandOff('Channels'); + expect(tileNode('Discord')).toHaveClass('v2-cx-tile--dim'); + expect(tileNode('Sentry')).toBeNull(); + // The head counts what is on or offered, never what is off. + expect(document.querySelector('.v2-cx-head__meta')).toHaveTextContent('0 on · 2 available'); + + await expandOff('Tools'); + expect(tileNode('Sentry')).toHaveClass('v2-cx-tile--dim'); + + const channelsOff = within(screen.getByRole('region', { name: 'Channels' })).getByRole('button', { name: '+1 off' }); + expect(channelsOff).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(channelsOff); + expect(tileNode('Discord')).toBeNull(); + }); + + it('an unconnected tile opens a popover with the act; Escape closes it and returns focus to the tile', async () => { + mockCatalog([channel('telegram', 'Telegram'), tool('github', 'GitHub')]); + renderPage(); + + const tile = await findTile('Telegram'); + expect(tile).toHaveAttribute('aria-expanded', 'false'); + fireEvent.click(tile); + const popover = await findPopover('Telegram'); + expect(tile).toHaveAttribute('aria-expanded', 'true'); + expect(within(popover).getByText('Telegram copy.')).toBeInTheDocument(); + const act = within(popover).getByRole('button', { name: 'Add' }); + // Focus moves into the popover on open. + await waitFor(() => expect(act).toHaveFocus()); + expect(screen.queryByRole('region', { name: 'Telegram' })).toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Telegram' })).toBeNull()); + expect(tile).toHaveFocus(); + expect(tile).toHaveAttribute('aria-expanded', 'false'); + }); + + it('an unconnected tool tile reveals its description, the first tool names as chips, and the act', async () => { + const names = ['a.one', 'b.two', 'c.three', 'd.four', 'e.five']; + mockCatalog([tool('github', 'GitHub', { tools: names.map((name) => ({ name, requiredWriteMode: 'read', irreversible: false })) })], { grants: [] }); + renderPage(); + + await clickTile('GitHub'); + const popover = await findPopover('GitHub'); + expect(within(popover).getByText('GitHub tool copy.')).toBeInTheDocument(); + for (const name of names.slice(0, 3)) expect(within(popover).getByText(name)).toBeInTheDocument(); + expect(within(popover).queryByText('d.four')).toBeNull(); + expect(within(popover).getByText('+2')).toBeInTheDocument(); + expect(within(popover).getByRole('button', { name: 'Add' })).toBeInTheDocument(); + }); + + it('a connected tile opens a drawer with its panel, and the close button closes it', async () => { + mockCatalog([liveSlack, channel('telegram', 'Telegram')]); + renderPage(); + + const tile = await findTile('Slack'); + expect(tile).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(tile); + const drawer = await findDrawer('Slack'); + expect(tile).toHaveAttribute('aria-pressed', 'true'); + expect(within(drawer).getByRole('heading', { name: 'Slack' })).toBeInTheDocument(); + expect(within(drawer).getByText('What the channel sees')).toBeInTheDocument(); + // A connected tile opens a manage drawer, never the reveal popover. + expect(screen.queryByRole('dialog', { name: 'Slack' })).toBeNull(); + + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(screen.queryByRole('region', { name: 'Slack' })).toBeNull(); + expect(tile).toHaveAttribute('aria-pressed', 'false'); + }); + + it('a second tile replaces the open drawer, and Escape closes the drawer', async () => { + mockCatalog([liveSlack, channel('telegram', 'Telegram', { + installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: 'p1', components: [] }, + integration: { ...liveSlack.integration, _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }, + })]); + renderPage(); + + await clickTile('Slack'); + await findDrawer('Slack'); + await clickTile('Telegram'); + await findDrawer('Telegram'); + expect(screen.queryByRole('region', { name: 'Slack' })).toBeNull(); + + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('region', { name: 'Telegram' })).toBeNull()); + }); + + it('the add tile opens the add form in the drawer, and Close dismisses it', async () => { + mockCatalog([channel('telegram', 'Telegram'), channel('slack', 'Slack')]); + renderPage(); + + const add = await findTile('Channel'); + expect(add).toHaveClass('v2-cx-tile--add'); + fireEvent.click(add); + const drawer = await findDrawer('Connect a channel'); + expect(within(drawer).getByText('Next step')).toBeInTheDocument(); + expect(within(drawer).getByRole('group', { name: 'Channel provider' })).toBeInTheDocument(); + expect(within(drawer).getByLabelText('Pod to bridge')).toBeInTheDocument(); + expect(within(drawer).getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + + fireEvent.click(within(drawer).getByRole('button', { name: 'Close' })); + expect(screen.queryByRole('region', { name: 'Connect a channel' })).toBeNull(); + }); + + it('a tile mark says what it means: the dot is named, a locked tool has its sentence, a beta tool its word', async () => { + mockCatalog([ + liveSlack, + tool('github', 'GitHub', { connections: [] }), + tool('atlassian', 'Atlassian', { entryId: 'atlassian', connectionType: 'hosted-mcp' }), + ]); + renderPage(); + + await findTile('Slack'); + expect(within(tileNode('Slack')).getByRole('img', { name: /Rewire crew · linked to Rewire Live Demo/ })).toBeInTheDocument(); + expect(within(tileNode('GitHub')).getByRole('img', { name: 'An admin installs this once' })).toBeInTheDocument(); + const beta = tileNode('Atlassian').querySelector('.v2-cx-tile__beta'); + expect(beta).toHaveTextContent('beta'); + expect(beta).toHaveAttribute('title', 'read in testing'); + }); + + it('shows the page-level error when the first read fails', async () => { + mockApi({ pods }); + axios.get.mockImplementation((url) => (url === '/api/integrations/user/all' + ? Promise.reject(new Error('down')) + : Promise.resolve({ data: [] }))); + renderPage(); + + await pageReady(); + expect(screen.getByRole('alert')).toHaveClass('v2-cx-error'); + }); + it('keeps a live grant listed when its catalogue entry is gone, so it can still be revoked', async () => { + const iso = (ms) => new Date(Date.now() + ms).toISOString(); + const orphan = { + grantId: 'grant_orphan', installationId: 'inst-x', target: { kind: 'pod', id: 'p1' }, tools: ['retired.read'], + writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: iso(86_400_000), revokedAt: null, revokedBy: null, + parentGrantId: null, rootGrantId: null, createdAt: iso(-3_600_000), grantedBy: 'u1', + }; + mockCatalog([tool('github', 'GitHub')], { grants: [orphan] }); + renderPage(); + + await findTile('GitHub'); + const row = await screen.findByRole('button', { name: /View Unknown connector/ }); + fireEvent.click(row); + expect(await findDrawer(/Unknown connector/)).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /^Revoke/ })).toBeInTheDocument(); + }); + + it('closes the reveal when its act opens a drawer', async () => { + mockCatalog([tool('github', 'GitHub')]); + renderPage(); + + await clickTile('GitHub'); + const pop = await findPopover('GitHub'); + fireEvent.click(within(pop).getByRole('button', { name: 'Add' })); + await findDrawer('GitHub'); + expect(screen.queryByRole('dialog', { name: 'GitHub' })).toBeNull(); + + // And it stays closed once the drawer closes; it does not come back. + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('region', { name: 'GitHub' })).toBeNull()); + expect(screen.queryByRole('dialog', { name: 'GitHub' })).toBeNull(); + }); + + it('does not refetch in a loop while the pod list cannot load', async () => { + mockCatalog([tool('github', 'GitHub')], { pods: new Error('pods down') }); + renderPage(); + + await findTile('GitHub'); + await new Promise((resolve) => { setTimeout(resolve, 300); }); + const grantCalls = axios.get.mock.calls.filter(([url]) => url === '/api/grants').length; + expect(grantCalls).toBeLessThanOrEqual(2); + }); + + it('keeps the header count steady while searching', async () => { + mockCatalog([channel('telegram', 'Telegram'), liveSlack, tool('github', 'GitHub'), tool('linear', 'Linear')]); + renderPage(); + + await findTile('GitHub'); + const meta = () => document.querySelector('.v2-cx-head__meta'); + expect(meta()).toHaveTextContent('1 on · 3 available'); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'lin' } }); + await findTile('Linear'); + expect(meta()).toHaveTextContent('1 on · 3 available'); + }); + + it('gives an installed provider this instance no longer offers the reveal, not an empty drawer', async () => { + mockCatalog([ + channel('discord', 'Discord', { + available: false, + installation: { status: 'active', updatedAt: new Date().toISOString(), boundPodId: null, components: [] }, + }), + tool('github', 'GitHub'), + ]); + renderPage(); + + await findTile('GitHub'); + await expandOff('Channels'); + fireEvent.click(await findTile('Discord')); + expect(await findPopover('Discord')).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'Discord' })).toBeNull(); + }); +}); diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx new file mode 100644 index 0000000000..2bddc2c0b2 --- /dev/null +++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx @@ -0,0 +1,1301 @@ +// @ts-nocheck +// Connectors page, tools half: grants and their trail, the Add / Change access form, +// hosted connect and disconnect, the GitHub App setup. A tool tile with a grant (or a +// hidden connection) opens its drawer; one without opens a popover with the act. +// Nothing here draws a control the server does not enforce. +import React from 'react'; +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; +import i18n, { i18nReady } from '../../i18n'; +import { + axios, authValue, adminAuth, asUser, mockApi, renderPage, tileNode, findTile, findDrawer, findPopover, + expandOff, emptyCounts, +} from '../__testkit__/connectorsKit'; + +jest.mock('axios', () => require('../__testkit__/axiosMock').axiosFactory()); + +const NOW = Date.now(); +const iso = (ms) => new Date(NOW + ms).toISOString(); + +const scout = { name: 'scout', displayName: 'Scout', userId: 'a1' }; +const pods = [ + { _id: 'p1', name: 'Launch pod', type: 'chat', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] }, + { _id: 'p2', name: 'Ops', type: 'chat', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] }, +]; +const grantLive = { + grantId: 'grant_live', installationId: 'inst-1', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues', 'github.comment_on_issue'], + writeMode: 'write-with-confirm', budget: { calls: 50, windowMs: 3600000 }, effectiveAudience: ['a1'], expiresAt: iso(6 * 86400000), + revokedAt: null, revokedBy: null, parentGrantId: null, rootGrantId: null, createdAt: iso(-3600000), grantedBy: 'u1', +}; +const grantRevoked = { ...grantLive, grantId: 'grant_gone', target: { kind: 'pod', id: 'p2' }, writeMode: 'read', effectiveAudience: ['a1'], revokedAt: iso(-600000), revokedBy: 'u1', createdAt: iso(-86400000) }; +const trail = { + grantId: 'grant_live', + calls: [ + { callId: 'c1', agentUserId: 'a1', tool: 'github.list_issues', outcome: 'ok', reason: null, approvalId: null, argsDigest: 'a'.repeat(64), at: iso(-120000), durationMs: 80 }, + { callId: 'c2', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'refused', reason: 'not_in_audience', approvalId: null, argsDigest: 'b'.repeat(64), at: iso(-300000), durationMs: null }, + { callId: 'c3', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'pending_approval', reason: null, approvalId: 'appr-1', argsDigest: 'c'.repeat(64), at: iso(-400000), durationMs: null }, + // C4-11: the ask was answered and the call ran as the row above it; the + // server reports the parked row as `superseded`, never as still awaiting. + { callId: 'c4', agentUserId: 'a1', tool: 'github.close_issue', outcome: 'superseded', reason: null, approvalId: 'appr-2', argsDigest: 'd'.repeat(64), at: iso(-500000), durationMs: null }, + ], + counts: { total: 3, ok: 1, refused: 1, pending_approval: 1, failed: 0 }, +}; + +const githubEntry = { + installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues and pull requests.', available: true, + broker: { id: 'commonly-grant-broker' }, + tools: [ + { name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }, + { name: 'github.comment_on_issue', requiredWriteMode: 'write', irreversible: true }, + { name: 'github.close_issue', requiredWriteMode: 'write', irreversible: true }, + ], + connections: [{ connectionId: 'conn-1', owner: 'Team-Commonly', repo: 'commonly' }], +}; +const linearEntry = { + installableId: 'linear', list: 'tools', label: 'Linear', description: 'Issues in Linear.', available: true, + connectionType: 'hosted-mcp', entryId: 'linear', + broker: { id: 'commonly-grant-broker' }, + tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }], + connections: [{ connectionId: 'conn-2', owner: 'Team-Commonly', repo: 'linear' }], +}; +const grantLinear = { ...grantLive, grantId: 'grant_lin', installationId: 'inst-2', writeMode: 'read', tools: ['linear.list_issues'] }; + +// The tools hook reads the catalogue first and the grants a beat later, so a tile +// exists before the page knows whether it has a grant. Wait that beat out before a +// click: a granted tool opens a drawer, an ungranted one a popover. +const settle = () => act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); +const openTool = async (name) => { + await findTile(name); + await settle(); + fireEvent.click(tileNode(name)); + return findDrawer(name); +}; +const openToolPopover = async (name) => { + await findTile(name); + await settle(); + fireEvent.click(tileNode(name)); + return findPopover(name); +}; +const openRow = async (toolName, rowName) => { + const drawer = await openTool(toolName); + fireEvent.click(within(drawer).getByRole('button', { name: rowName })); + const aside = await within(drawer).findByRole('complementary', { name: 'Grant details' }); + return { drawer, aside }; +}; +const grantReads = () => axios.get.mock.calls.filter(([url]) => url === '/api/grants'); +const meta = () => document.querySelector('.v2-cx-head__meta'); + +// Defaults mirror the old suite: one GitHub entry, a live grant in Launch pod and a +// revoked one in Ops, one agent (Scout) in each pod. +const mockTools = ({ + entries = [githubEntry], + grants = [grantLive, grantRevoked], + podList = pods, + seats = { p1: [scout], p2: [scout] }, + trails = { grant_live: trail }, + hidden, + route, +} = {}) => mockApi({ installables: entries, grants, pods: podList, seats, trails, hidden, route }); + +beforeEach(() => { + jest.clearAllMocks(); + window.history.replaceState({}, '', '/v2/connectors'); + mockTools(); + axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } }); +}); + +afterEach(() => jest.restoreAllMocks()); + +describe('Connectors page: tools', () => { + it('Atlassian shows its read-in-testing state on a tile mark and on an existing grant', async () => { + const atlassian = { + ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian', + tools: [{ name: 'atlassian.get_accessible_atlassian_resources', requiredWriteMode: 'read', irreversible: false }], + }; + const grant = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.get_accessible_atlassian_resources'] }; + mockTools({ entries: [atlassian], grants: [grant] }); + renderPage(); + + const tile = await findTile('Atlassian'); + const beta = tile.querySelector('.v2-cx-tile__beta'); + expect(beta).toHaveTextContent('beta'); + expect(beta).toHaveAttribute('title', 'read in testing'); + const drawer = await openTool('Atlassian'); + expect(within(drawer).getByText(/read in testing/)).toBeInTheDocument(); + }); + + it('one owner grant read shows the 21st pod and keeps revoked and expired labels', async () => { + const manyPods = Array.from({ length: 21 }, (_, index) => ({ + _id: `p${index + 1}`, name: `Pod ${index + 1}`, type: 'chat', members: [{ _id: 'u1', username: 'sam' }], + })); + const inLastPod = { ...grantLive, grantId: 'grant_21', target: { kind: 'pod', id: 'p21' } }; + const revoked = { ...inLastPod, grantId: 'grant_21_revoked', revokedAt: iso(-600000), revokedBy: 'u1' }; + const expired = { ...inLastPod, grantId: 'grant_21_expired', expiresAt: iso(-600000) }; + mockTools({ grants: [inLastPod, revoked, expired], podList: manyPods, seats: {}, trails: {} }); + renderPage(); + + const drawer = await openTool('GitHub'); + expect(await within(drawer).findAllByRole('button', { name: 'View GitHub in Pod 21' })).toHaveLength(3); + expect(within(drawer).getByText(/revoked by sam 10m/)).toBeInTheDocument(); + expect(within(drawer).getByText(/expired 10m/)).toBeInTheDocument(); + fireEvent.click(within(drawer).getAllByRole('button', { name: 'Grant again' })[0]); + expect(await screen.findByRole('option', { name: 'Pod 21' })).toBeInTheDocument(); + // The grant list is one read for the owner, never one per pod. The hook reads once + // before the pod list arrives and once after, so two is the ceiling for 21 pods. + const reads = grantReads(); + expect(reads.length).toBeLessThanOrEqual(2); + for (const read of reads) expect(read[1]).toMatchObject({ params: { connectionOwner: 'me' } }); + expect(axios.get.mock.calls.some(([url]) => /\/api\/pods\/.*\/grants/.test(url))).toBe(false); + }); + + it('a sole join-policy refusal offers the owner an in-place fix and then allows retry', async () => { + mockTools({ podList: [pods[0], { ...pods[1], createdBy: { _id: 'u1' } }] }); + axios.post.mockRejectedValueOnce({ response: { data: { + error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.', + } } }).mockResolvedValueOnce({ data: { grantId: 'grant_new' } }); + axios.patch.mockResolvedValue({ data: { id: 'p2', joinPolicy: 'invite-only' } }); + renderPage(); + + const drawer = await openTool('GitHub'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' })); + const aside = await screen.findByRole('complementary', { name: 'Add GitHub' }); + fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); + fireEvent.click(await within(aside).findByRole('button', { name: 'Make this pod invite-only' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalledWith('/api/pods/p2/join-policy', { joinPolicy: 'invite-only' }, expect.anything())); + expect(await within(aside).findByText('This pod is invite-only. Select Grant to continue.')).toBeInTheDocument(); + fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); + }); + + it('the in-place fix is not offered to a non-owner', async () => { + axios.post.mockRejectedValueOnce({ response: { data: { + error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.', + } } }); + renderPage(); + + const drawer = await openTool('GitHub'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' })); + const aside = await screen.findByRole('complementary', { name: 'Add GitHub' }); + fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); + await within(aside).findByText('Agents can self-install into this pod. Make it invite-only to use this connector.'); + expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument(); + }); + + it('a bridged-pod refusal never offers the join-policy fix', async () => { + mockTools({ podList: [pods[0], { ...pods[1], createdBy: 'u1' }] }); + axios.post.mockRejectedValueOnce({ response: { data: { + error: 'outsider_grant_pod_bridged', message: 'Use a separate unbridged pod for this connector.', + } } }); + renderPage(); + + const drawer = await openTool('GitHub'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' })); + const aside = await screen.findByRole('complementary', { name: 'Add GitHub' }); + fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); + await within(aside).findByText('Use a separate unbridged pod for this connector.'); + expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument(); + }); + + it('TASK-131: a grant age advances in place, and a returning tab re-reads, without a reload', async () => { + jest.useFakeTimers(); + try { + renderPage(); + // The Granted dot only exists once the grants have been read. + await screen.findByRole('img', { name: 'Granted' }); + fireEvent.click(tileNode('GitHub')); + expect(await screen.findByText(/granted 1h$/)).toBeInTheDocument(); + + await act(async () => { jest.advanceTimersByTime(60 * 60_000); }); + expect(screen.getByText(/granted 2h$/)).toBeInTheDocument(); + + const before = grantReads().length; + document.dispatchEvent(new Event('visibilitychange')); + await waitFor(() => expect(grantReads().length).toBeGreaterThan(before)); + } finally { + jest.useRealTimers(); + } + }); + + it('TASK-131: a grant expiring with the page open takes its tool off the count and the tile on the same tick', async () => { + jest.useFakeTimers(); + try { + // Live for one more minute, so the only thing that changes is the clock. + mockTools({ grants: [{ ...grantLive, grantId: 'grant_soon', expiresAt: iso(60_000) }], seats: {}, trails: {} }); + renderPage(); + await screen.findByRole('img', { name: 'Granted' }); + await waitFor(() => expect(meta()).toHaveTextContent('1 on · 0 available')); + + await act(async () => { jest.advanceTimersByTime(2 * 60_000); }); + + // The count always followed the clock; the tile did not, so the two disagreed + // on screen until a reload. + expect(meta()).toHaveTextContent('0 on · 1 available'); + expect(screen.queryByRole('img', { name: 'Granted' })).toBeNull(); + } finally { + jest.useRealTimers(); + } + }); + + it('rows carry the states table: a live grant pulses when used in the last 10 minutes, a revoked one goes hollow', async () => { + renderPage(); + await findTile('GitHub'); + await waitFor(() => expect(meta()).toHaveTextContent('1 on · 0 available')); + expect(screen.getByRole('img', { name: 'Granted' })).toBeInTheDocument(); + const drawer = await openTool('GitHub'); + const live = within(drawer).getByRole('button', { name: 'View GitHub in Launch pod' }); + expect(within(live).getByText('Launch pod')).toBeInTheDocument(); + expect(within(live).getByText('sam')).toBeInTheDocument(); + // Direction A: the audience is the sentence; the write mode is the glyph (label) and rides the 390 kicker as words. + expect(within(live).getByText('Scout may use it')).toBeInTheDocument(); + expect(within(live).getByRole('img', { name: /asks|write/ })).toBeInTheDocument(); + expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first'); + expect(within(live).getByText(/granted 1h$/)).toBeInTheDocument(); + const liveDot = live.querySelector('.v2-connector-row__dot'); + expect(liveDot).toHaveClass('v2-connector-row__dot--live'); + await waitFor(() => expect(liveDot).toHaveClass('v2-connector-row__dot--pulse')); + const gone = within(drawer).getByRole('button', { name: 'View GitHub in Ops' }); + expect(within(gone).getByText('revoked by sam 10m ago')).toBeInTheDocument(); + expect(gone.closest('.v2-connector-row')).toHaveClass('v2-connector-row--dead'); + expect(gone.querySelector('.v2-connector-row__dot')).toHaveClass('v2-connector-row__dot--empty'); + // The ended grant sits under its own heading, below the live one. + expect(within(drawer).getByText('inactive · 1')).toBeInTheDocument(); + // The entry is granted, so the drawer offers no Add. + expect(within(drawer).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + expect(within(drawer).getAllByRole('button', { name: 'Manage' })).toHaveLength(1); + expect(within(drawer).getByRole('button', { name: 'Grant again' })).toBeInTheDocument(); + // Direction A: Manage is the gear (word in title + aria-label), and the age rides the kicker. + for (const manage of within(drawer).getAllByRole('button', { name: 'Manage' })) { + expect(manage).toHaveClass('v2-connector-row__action--icon'); + expect(manage.textContent).toBe(''); + } + expect(document.querySelector('.v2-connector-row__when')).toBeNull(); + expect(document.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/ · granted /); + }); + + // TASK-649: one timestamp pair, two ages. A grant made 16 minutes before its + // revoke and read 41h later must say the same day count for both lines, not + // "granted 1d" beside "revoked 2d ago". + it('TASK-649: the grant age and the revoke age round the same way on one row', async () => { + const createdAt = iso(-(41 * 3600_000 + 24 * 60_000)); + const revokedAt = iso(-(41 * 3600_000 + 8 * 60_000)); + mockTools({ grants: [{ ...grantRevoked, createdAt, revokedAt, revokedBy: 'u1' }] }); + renderPage(); + + const drawer = await openTool('GitHub'); + const gone = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' }); + expect(within(gone).getByText(/granted 2d$/)).toBeInTheDocument(); + expect(within(gone).getByText('revoked by sam 2d ago')).toBeInTheDocument(); + }); + + // TASK-649: the same rule holds below a day. + it.each([ + [5 * 60_000 + 40_000, 'granted 6m'], + [100 * 60_000, 'granted 2h'], + ])('TASK-649: a grant aged %p ms reads "%s" on its row', async (ageMs, expected) => { + mockTools({ grants: [{ ...grantRevoked, createdAt: iso(-ageMs), revokedAt: null, revokedBy: null }] }); + renderPage(); + + const drawer = await openTool('GitHub'); + const row = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' }); + expect(within(row).getByText(new RegExp(`${expected}$`))).toBeInTheDocument(); + }); + + it('TASK-164: the grant, budget and trail times read in zh-CN, not English', async () => { + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + renderPage(); + const drawer = await openTool('GitHub'); + fireEvent.click(drawer.querySelector('.v2-connector-row__selection')); + const aside = await screen.findByRole('complementary'); + expect(within(aside).getByText(/前由 sam 授权。/)).toBeInTheDocument(); + expect(within(aside).getByText(/后结束。/)).toBeInTheDocument(); + // The TASK-180 ruling names this key explicitly: 「每{{window}}可调用{{calls}}次」. + expect(within(aside).getByText(/每1小时可调用50次/)).toBeInTheDocument(); + await waitFor(() => expect(within(aside).getAllByRole('listitem')).toHaveLength(4)); + const lines = within(aside).getAllByRole('listitem'); + expect(lines.map((line) => line.textContent)).toEqual([ + 'Scout · github.list_issues · 成功2分钟前', + 'Scout · github.comment_on_issue · 已拒绝5分钟前', + 'Scout · github.comment_on_issue · 等待人工确认7分钟前', + 'Scout · github.close_issue · 已回答8分钟前', + ]); + // The whole aside: an English relative time inside an otherwise-Chinese line is the regression. + expect(aside.textContent).not.toMatch(/\bago\b|from now|\bm ago\b/); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + it('TASK-179: the grant line is one sentence per language, and an empty audience is its own sentence', async () => { + const emptyAudience = { ...grantLive, grantId: 'grant_empty', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [] }; + mockTools({ grants: [grantLive, emptyAudience], seats: { p1: [scout], p2: [] } }); + renderPage(); + + const drawer = await openTool('GitHub'); + const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' }); + const ops = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' }); + // One key holds both names, so the language orders them. + expect(live.querySelector('strong')?.textContent).toBe('granted to Launch pod by sam'); + expect(within(ops).getByText('nobody may use it')).toBeInTheDocument(); + + fireEvent.click(live); + await screen.findByRole('complementary', { name: 'Grant details' }); + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + // The accessible name is translated too, so locate rows by what they say. + const rows = () => Array.from(document.querySelectorAll('.v2-connector-row')); + const rowSaying = (text) => rows().find((row) => row.textContent?.includes(text)); + expect(rowSaying('Launch pod').querySelector('strong')?.textContent).toBe('由 sam 授权给 Launch pod'); + expect(rowSaying('Launch pod').textContent).toContain('Scout 可以使用'); + expect(rowSaying('Ops').textContent).toContain('没有智能体可以使用'); + // The separator key is zh-empty: two sentences that each end in 。 join with nothing. + const aside = document.querySelector('.v2-connectors__aside'); + expect(within(aside).getByText(/授权。/).textContent).toBe('1小时前由 sam 授权。6天后结束。'); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + it('TASK-179: a seat grant names the seat in its sentence and the pod in the row it sits in', async () => { + // The sentence's subject and the row's location are different nouns, so they + // are two expressions, and this test fails if they are ever re-merged. + const seatGrant = { ...grantLive, grantId: 'grant_seat', target: { kind: 'seat', id: 'a1' }, effectiveAudience: ['a1'] }; + mockTools({ grants: [seatGrant], seats: { p1: [], p2: [{ name: 'reed', displayName: 'Reed', userId: 'a1' }] } }); + renderPage(); + + const drawer = await openTool('GitHub'); + // Found by the pod's name: if the seat leaks back into the accessible name + // this query returns nothing, which is the fix's own control. + const row = await within(drawer).findByRole('button', { name: 'View GitHub in Ops' }); + expect(row.querySelector('strong')?.textContent).toBe('granted to Reed by sam'); + expect(row.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/^Ops · granted /); + }); + + it('the aside reads the grant and the trail: agents, allow-list under its mode, what asks first, three counts, outcomes as words', async () => { + renderPage(); + const { aside } = await openRow('GitHub', 'View GitHub in Launch pod'); + expect(within(aside).getByRole('heading', { name: 'GitHub · Launch pod' })).toBeInTheDocument(); + expect(within(aside).getByText(/Granted by sam 1h ago\. Ends 6d from now\./)).toBeInTheDocument(); + expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('Scout'); + expect(within(aside).getByText('read and write, ask first').nextElementSibling).toHaveTextContent('github.list_issues'); + expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first'); + expect(within(aside).getByText('budget').nextElementSibling).toHaveTextContent('50 calls per 1h'); + await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3')); + expect(within(aside).getByText('refused').previousElementSibling).toHaveTextContent('1'); + expect(within(aside).getByText('awaiting a person').previousElementSibling).toHaveTextContent('1'); + const lines = within(aside).getAllByRole('listitem'); + expect(lines.map((line) => line.textContent)).toEqual([ + 'Scout · github.list_issues · ok2m ago', + 'Scout · github.comment_on_issue · refused5m ago', + 'Scout · github.comment_on_issue · awaiting a person7m ago', + 'Scout · github.close_issue · answered8m ago', + ]); + // A superseded request is not a call: the counts still say 3 while the trail + // lists its 4 rows, and only the genuinely open one is counted as awaiting. + expect(lines.map((line) => line.textContent).filter((text) => text.includes('awaiting a person'))).toHaveLength(1); + // `answered` covers a resolution that was declined or expired as well as one + // that ran, so its mark says SETTLED without saying approved: shield plus a short bar. + const answeredLine = lines.find((line) => line.textContent?.includes('answered')); + expect(answeredLine?.querySelectorAll('svg path')).toHaveLength(2); + expect(Array.from(answeredLine?.querySelectorAll('svg path') || []).map((path) => path.getAttribute('d'))).toEqual([ + 'M12 3 4 6v6c0 5 3.4 8.4 8 9 4.6-.6 8-4 8-9V6z', + 'M9 12h6', + ]); + // The arguments never reach the page: only the digest does, and it is not rendered. + expect(aside.textContent).not.toContain('a'.repeat(64)); + expect(axios.get).toHaveBeenCalledWith('/api/grants/grant_live/calls', expect.objectContaining({ headers: expect.any(Object) })); + }); + + it('TASK-050: a human member in the audience is named, not called "an agent"', async () => { + mockTools({ grants: [{ ...grantLive, effectiveAudience: ['u1', 'a1'] }], seats: { p1: [scout] } }); + renderPage(); + + const drawer = await openTool('GitHub'); + const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' }); + expect(within(live).getByText('sam, Scout may use it')).toBeInTheDocument(); + expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first'); + expect(screen.queryByText(/an agent/)).not.toBeInTheDocument(); + + fireEvent.click(live); + const aside = await screen.findByRole('complementary', { name: 'Grant details' }); + expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('sam, Scout'); + }); + + it('TASK-624: a per-user bot member with no roster row is named by its curated label, not its instance id', async () => { + mockTools({ + grants: [grantLive], + seats: {}, + podList: [{ + _id: 'p1', name: 'Launch pod', type: 'chat', + members: [ + { _id: 'u1', username: 'sam' }, + { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: 'Scout', instanceId: 'uf2554d19c9' } }, + ], + }], + }); + renderPage(); + + const drawer = await openTool('GitHub'); + const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' }); + expect(within(live).getByText('Scout may use it')).toBeInTheDocument(); + expect(screen.queryByText(/uf2554d19c9/)).not.toBeInTheDocument(); + }); + + it('TASK-624: a bot member with a blank displayName falls back to its non-default instanceId, then to the default username', async () => { + mockTools({ + grants: [{ ...grantLive, effectiveAudience: ['a1', 'a2'] }], + seats: {}, + podList: [{ + _id: 'p1', name: 'Launch pod', type: 'chat', + members: [ + { _id: 'u1', username: 'sam' }, + { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: ' ', instanceId: 'uf2554d19c9' } }, + { _id: 'a2', username: 'guide', isBot: true, botMetadata: { displayName: '', instanceId: 'default' } }, + ], + }], + }); + renderPage(); + + const drawer = await openTool('GitHub'); + const live = await within(drawer).findByRole('button', { name: 'View GitHub in Launch pod' }); + expect(within(live).getByText('uf2554d19c9, guide may use it')).toBeInTheDocument(); + }); + + it('Revoke is two-click and posts the revoke verb, then reloads; a dead grant offers no Revoke', async () => { + renderPage(); + const { drawer, aside } = await openRow('GitHub', 'View GitHub in Launch pod'); + expect(axios.post).not.toHaveBeenCalled(); + const before = grantReads().length; + fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' })); + expect(axios.post).not.toHaveBeenCalled(); + fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants/grant_live/revoke', undefined, expect.any(Object))); + await waitFor(() => expect(grantReads().length).toBeGreaterThan(before)); + + fireEvent.click(within(drawer).getByRole('button', { name: 'View GitHub in Ops' })); + const gone = await screen.findByRole('complementary', { name: 'Grant details' }); + expect(within(gone).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument(); + expect(within(gone).getByText(/Revoked by sam 10m ago\./)).toBeInTheDocument(); + await waitFor(() => expect(within(gone).getByText('No calls yet.')).toBeInTheDocument()); + }); + + // Atlassian walk, launch day: the card kept saying "granted" after Revoke until a + // reload. A confirmed revoke is final, so a stale or failed re-read cannot put the + // card back. + it('after Revoke the grant card says revoked in place, even when the re-read is stale or fails', async () => { + let staleServed = false; + mockTools({ + grants: [grantLive], + trails: {}, + route: (url) => { + if (url !== '/api/grants') return undefined; + if (axios.post.mock.calls.length === 0) return Promise.resolve({ data: { grants: [grantLive] } }); + if (!staleServed) { + staleServed = true; // stale: the server read lags the revoke + return Promise.resolve({ data: { grants: [grantLive] } }); + } + return Promise.reject(new Error('network')); // then a failed read + }, + }); + axios.post.mockResolvedValue({ data: { revoked: 1 } }); + renderPage(); + + const { drawer, aside } = await openRow('GitHub', 'View GitHub in Launch pod'); + expect(within(aside).getByText(/Granted by sam .*Ends /)).toBeInTheDocument(); + fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' })); + fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' })); + await waitFor(() => expect(staleServed).toBe(true)); + // A tab refocus re-reads, and that read fails. + const reads = grantReads().length; + fireEvent(document, new Event('visibilitychange')); + await waitFor(() => expect(grantReads().length).toBeGreaterThan(reads)); + const card = await screen.findByRole('complementary', { name: 'Grant details' }); + await waitFor(() => expect(within(card).getByText(/Revoked by sam just now\./)).toBeInTheDocument()); + expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument(); + // The row went hollow with it, and the list did not blank on the failed read. + expect(within(drawer).getByRole('button', { name: 'View GitHub in Launch pod' })).toBeInTheDocument(); + expect(within(drawer).getByText(/revoked by sam/)).toBeInTheDocument(); + }); + + // Atlassian walk, launch day: only a connection that is no longer offered got + // "Remove connection", so nobody could disconnect Atlassian from the page. + describe('Disconnect on a live connection of an offered hosted connector', () => { + const atlassian = { + ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian', + tools: [{ name: 'atlassian.search', requiredWriteMode: 'read', irreversible: false }], + connections: [{ connectionId: 'conn-atl', owner: 'me', repo: 'atlassian' }], + }; + const grantAtl = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.search'] }; + const catalogReads = () => axios.get.mock.calls.filter(([u]) => u === '/api/installables'); + + it('an ungranted connection offers Disconnect, two clicks, and says so afterwards', async () => { + mockTools({ entries: [atlassian], grants: [] }); + axios.delete.mockResolvedValue({ data: { message: 'Integration deleted successfully' } }); + renderPage(); + + const popover = await openToolPopover('Atlassian'); + fireEvent.click(within(popover).getByRole('button', { name: 'Disconnect Atlassian' })); + expect(axios.delete).not.toHaveBeenCalled(); + const reads = catalogReads().length; + fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object))); + expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument(); + await waitFor(() => expect(catalogReads().length).toBeGreaterThan(reads)); + }); + + it('a stale catalogue read after Disconnect does not bring the connection back', async () => { + mockTools({ entries: [atlassian], grants: [] }); // every read still lists the connection + axios.delete.mockResolvedValue({ data: { message: 'ok' } }); + renderPage(); + + const popover = await openToolPopover('Atlassian'); + fireEvent.click(within(popover).getByRole('button', { name: 'Disconnect Atlassian' })); + const reads = catalogReads().length; + fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' })); + expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument(); + // The re-read that follows still lists the connection; the page does not. + await waitFor(() => expect(catalogReads().length).toBeGreaterThan(reads)); + expect(screen.queryByRole('button', { name: /Disconnect Atlassian|Yes, disconnect/ })).not.toBeInTheDocument(); + expect(within(popover).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + }); + + it('a granted connection offers it in the grant card, and a provider page from the server is linked', async () => { + mockTools({ entries: [atlassian], grants: [grantAtl] }); + axios.delete.mockResolvedValue({ data: { message: 'ok', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } }); + renderPage(); + + const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod'); + fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' })); + fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object))); + const link = await screen.findByRole('link', { name: 'Open provider settings' }); + expect(link).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps'); + }); + + it('a network failure says so and keeps the connection', async () => { + mockTools({ entries: [atlassian], grants: [] }); + axios.delete.mockRejectedValue(new Error('network')); + renderPage(); + + const popover = await openToolPopover('Atlassian'); + fireEvent.click(within(popover).getByRole('button', { name: 'Disconnect Atlassian' })); + fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' })); + expect(await screen.findByText('Could not disconnect Atlassian.')).toBeInTheDocument(); + expect(within(popover).getByRole('button', { name: 'Yes, disconnect' })).toBeInTheDocument(); + }); + + // A 502 is the server's partial removal: grants already revoked, row disconnected, + // only the provider's revoke failed, and the body names the page to visit. + it('a 502 partial removal shows the grant revoked and links the provider page', async () => { + mockTools({ entries: [atlassian], grants: [grantAtl] }); + axios.delete.mockRejectedValue({ response: { status: 502, data: { code: 'provider_revoke_failed', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } } }); + renderPage(); + + const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod'); + fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' })); + fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); + expect(await screen.findByRole('link', { name: 'Open provider settings' })).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps'); + const card = await screen.findByRole('complementary', { name: 'Grant details' }); + expect(within(card).getByText(/Revoked by sam/)).toBeInTheDocument(); + expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument(); + }); + + // A 502 from the ingress while a pod restarts never reached the route: nothing was revoked. + it('a bare 502 (no route code) leaves the grant live and says the disconnect failed', async () => { + mockTools({ entries: [atlassian], grants: [grantAtl] }); + axios.delete.mockRejectedValue({ response: { status: 502, data: 'Bad gateway' } }); + renderPage(); + + const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod'); + fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' })); + fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); + expect(await within(aside).findByText('Could not disconnect Atlassian.')).toBeInTheDocument(); + expect(within(aside).getByRole('button', { name: 'Revoke' })).toBeInTheDocument(); + expect(within(aside).queryByText(/Revoked by/)).not.toBeInTheDocument(); + }); + + it('two connections of one entry offer no Disconnect, since a grant does not name its connection', async () => { + const two = { ...atlassian, connections: [...atlassian.connections, { connectionId: 'conn-atl-2', owner: 'me', repo: 'atlassian' }] }; + mockTools({ entries: [two], grants: [] }); + renderPage(); + + const popover = await openToolPopover('Atlassian'); + expect(within(popover).getByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument(); + }); + + it('GitHub (the instance App connection) never offers Disconnect', async () => { + renderPage(); + await openTool('GitHub'); + expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument(); + }); + }); + + it('a never-granted tool opens a popover that draws the offer, and Add mints the grant exactly as the server takes it, never a brokerId', async () => { + mockTools({ + grants: [], + seats: { p1: [scout, { name: 'hosted-smoke', userId: 'x9', internal: true }], p2: [] }, + }); + axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } }); + renderPage(); + + const popover = await openToolPopover('GitHub'); + await waitFor(() => expect(meta()).toHaveTextContent('0 on · 1 available')); + expect(within(popover).getByText('Issues and pull requests.')).toBeInTheDocument(); + expect(within(popover).getByText('read, or read and write')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Granted' })).toBeNull(); + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add GitHub' }); + // Default: read, every non-internal seat in the room, 7 days; under read only read tools are on the list. + expect(within(form).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(form).getByText('github.list_issues')).toBeInTheDocument(); + expect(within(form).queryByText('github.comment_on_issue')).toBeNull(); + expect(within(form).getByText('nothing asks first')).toBeInTheDocument(); + expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked(); + fireEvent.click(within(form).getByRole('button', { name: 'read and write' })); + expect(within(form).getByText('github.comment_on_issue')).toBeInTheDocument(); + expect(within(form).getByText('github.comment_on_issue, github.close_issue ask first')).toBeInTheDocument(); + fireEvent.change(within(form).getByRole('combobox', { name: 'ends' }), { target: { value: '30' } }); + const before = grantReads().length; + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)); + const [url, body] = axios.post.mock.calls[0]; + expect(url).toBe('/api/grants'); + expect(body).toMatchObject({ connectionId: 'conn-1', target: { kind: 'pod', id: 'p1' }, writeMode: 'write', audience: ['a1'], tools: ['github.list_issues', 'github.comment_on_issue', 'github.close_issue'] }); + // Both are the server's: the broker from the catalogue, the installation from the Connection (#1677). + expect(body).not.toHaveProperty('brokerId'); + expect(body).not.toHaveProperty('installationId'); + expect(Object.keys(body).sort()).toEqual(['audience', 'connectionId', 'expiresAt', 'target', 'tools', 'writeMode']); + const days = (new Date(body.expiresAt).getTime() - Date.now()) / 86400000; + expect(days).toBeGreaterThan(29.9); + expect(days).toBeLessThan(30.1); + await waitFor(() => expect(grantReads().length).toBeGreaterThan(before)); + }); + + it('an ended grant alone still draws the offer in its drawer, with Add and the modes', async () => { + mockTools({ grants: [grantRevoked] }); + renderPage(); + + const drawer = await openTool('GitHub'); + await waitFor(() => expect(meta()).toHaveTextContent('0 on · 1 available')); + expect(within(drawer).getByText('read, or read and write')).toBeInTheDocument(); + expect(within(drawer).getByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect(within(drawer).getByText('inactive · 1')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Granted' })).toBeNull(); + }); + + it('a tool without a Connection offers no Add, and a lock tile says an admin installs it once', async () => { + mockTools({ entries: [{ ...githubEntry, connections: [] }], grants: [] }); + renderPage(); + + await findTile('GitHub'); + expect(screen.getByRole('img', { name: 'An admin installs this once' })).toBeInTheDocument(); + const popover = await openToolPopover('GitHub'); + expect(within(popover).getByText('install the GitHub App first · an admin does this once')).toBeInTheDocument(); + expect(within(popover).queryByRole('button', { name: 'Add' })).toBeNull(); + expect(within(popover).queryByRole('button', { name: 'Install GitHub App' })).toBeNull(); + }); + + describe('the GitHub App setup (admin)', () => { + const noConnection = { ...githubEntry, connections: [] }; + const fillSetup = (form) => { + fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } }); + fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } }); + fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } }); + }; + + it('an admin can install the GitHub App from the no-connection tile', async () => { + mockTools({ entries: [noConnection], grants: [] }); + axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'u1' } } }); + renderPage({ auth: adminAuth() }); + + const popover = await openToolPopover('GitHub'); + fireEvent.click(within(popover).getByRole('button', { name: 'Install GitHub App' })); + const form = await screen.findByRole('complementary', { name: 'Install GitHub App' }); + fillSetup(form); + fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/integrations/github-app', + { installationId: 'install-42', owner: 'Team-Commonly', repo: 'commonly' }, + expect.any(Object), + )); + await waitFor(() => expect(screen.queryByRole('complementary', { name: 'Install GitHub App' })).toBeNull()); + }); + + it('an admin is told when another administrator already owns the GitHub App installation', async () => { + mockTools({ entries: [noConnection], grants: [] }); + axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'other-admin' } } }); + renderPage({ auth: adminAuth({ _id: 'u2' }) }); + + const popover = await openToolPopover('GitHub'); + fireEvent.click(within(popover).getByRole('button', { name: 'Install GitHub App' })); + const form = await screen.findByRole('complementary', { name: 'Install GitHub App' }); + fillSetup(form); + fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' })); + + await waitFor(() => expect(within(form).getByRole('alert')).toHaveTextContent('This GitHub App is already installed by another administrator.')); + expect(screen.getByRole('complementary', { name: 'Install GitHub App' })).toBeInTheDocument(); + }); + }); + + it('an unavailable tool folds under "+N off" and offers the same Ask affordance as unavailable channels', async () => { + mockTools({ entries: [{ ...githubEntry, available: false, connections: [] }], grants: [] }); + renderPage(); + + await screen.findByRole('region', { name: 'Tools' }); + await settle(); + expect(tileNode('GitHub')).toBeNull(); + await expandOff('Tools'); + expect(tileNode('GitHub')).toHaveClass('v2-cx-tile--dim'); + const popover = await openToolPopover('GitHub'); + const ask = within(popover).getByRole('link', { name: 'Ask' }); + expect(screen.queryByText('not enabled on this instance · ask your operator')).not.toBeInTheDocument(); + expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request'); + }); + + it('Change access mints a new grant then revokes the old one; Grant again on a dead grant only mints', async () => { + axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } }); + renderPage(); + + const { drawer, aside } = await openRow('GitHub', 'View GitHub in Launch pod'); + // Under `write` the aside names the irreversible tools from the catalogue. + expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first'); + fireEvent.click(within(aside).getByRole('button', { name: 'Change access' })); + const form = await screen.findByRole('complementary', { name: 'Change access' }); + expect(within(form).getByRole('button', { name: 'read and write, ask first' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked(); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); + expect(axios.post.mock.calls[0][0]).toBe('/api/grants'); + expect(axios.post.mock.calls[1][0]).toBe('/api/grants/grant_live/revoke'); + + axios.post.mockClear(); + // The first grant is revoked too now (the page keeps a confirmed revoke), so pick the Ops row's own button. + const body = await screen.findByRole('region', { name: 'GitHub' }); + fireEvent.click(within(within(body).getByRole('button', { name: 'View GitHub in Ops' }).closest('article')).getByRole('button', { name: 'Grant again' })); + const again = await screen.findByRole('complementary', { name: 'Add GitHub' }); + expect(within(again).getByRole('heading', { name: 'GitHub · Ops' })).toBeInTheDocument(); + fireEvent.click(within(again).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)); + expect(axios.post.mock.calls[0][0]).toBe('/api/grants'); + expect(drawer).toBeDefined(); + }); + + describe('a member who did not grant it', () => { + const member = asUser({ _id: 'u2', username: 'rae' }); + + it('Revoke renders only for the granter', async () => { + renderPage({ auth: member }); + const { aside } = await openRow('GitHub', 'View GitHub in Launch pod'); + expect(within(aside).getByText(/Granted by sam/)).toBeInTheDocument(); + expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull(); + expect(within(aside).queryByRole('button', { name: 'Change access' })).toBeNull(); + // The trail is the room's: a member still reads it. + await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3')); + }); + + it('Grant again renders only for the granter', async () => { + renderPage({ auth: member }); + const drawer = await openTool('GitHub'); + await within(drawer).findByRole('button', { name: 'View GitHub in Ops' }); + // The dead row offers a member Manage (read the grant), never the mint. + expect(within(drawer).queryByRole('button', { name: 'Grant again' })).toBeNull(); + expect(within(drawer).getAllByRole('button', { name: 'Manage' })).toHaveLength(2); + }); + }); + + it('Change access keeps the minted grant when the revoke fails and retries only the revoke', async () => { + axios.post.mockImplementation((url) => (url === '/api/grants' + ? Promise.resolve({ data: { grantId: 'grant_new' } }) + : Promise.reject(new Error('revoke down')))); + renderPage(); + + const { aside: first } = await openRow('GitHub', 'View GitHub in Launch pod'); + fireEvent.click(within(first).getByRole('button', { name: 'Change access' })); + fireEvent.click(within(await screen.findByRole('complementary', { name: 'Change access' })).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); + const aside = await screen.findByRole('complementary', { name: 'Grant details' }); + expect(await within(aside).findByText('The new grant is live. This old one still is too — its revoke did not go through.')).toBeInTheDocument(); + expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull(); + axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } }); + fireEvent.click(within(aside).getByRole('button', { name: 'Revoke the old grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(3)); + // Only the revoke went out again, no third mint. + expect(axios.post.mock.calls.map(([u]) => u)).toEqual(['/api/grants', '/api/grants/grant_live/revoke', '/api/grants/grant_live/revoke']); + }); + + describe('TASK-133: accounting with two entries', () => { + const dots = () => screen.queryAllByRole('img', { name: 'Granted' }); + + it('a live grant on one entry leaves the other entry still to grant', async () => { + mockTools({ entries: [githubEntry, linearEntry], grants: [grantLinear, grantRevoked] }); + renderPage(); + + // One entry granted, so one is still available, and the entry the grant names + // is the one that carries the Granted dot (label and glyph from its own entry). + await screen.findByRole('img', { name: 'Granted' }); + await waitFor(() => expect(meta()).toHaveTextContent('1 on · 1 available')); + expect(dots()).toHaveLength(1); + expect(tileNode('Linear').querySelector('.v2-cx-tile__dot')).not.toBeNull(); + expect(tileNode('GitHub').querySelector('.v2-cx-tile__dot')).toBeNull(); + + const linear = await openTool('Linear'); + expect(within(linear).getByRole('button', { name: 'View Linear in Launch pod' })).toBeInTheDocument(); + expect(within(linear).queryByRole('button', { name: /^View GitHub/ })).not.toBeInTheDocument(); + + const github = await openTool('GitHub'); + // Its only grant is the ended one, so the drawer carries the offer beside it. + expect(within(github).queryByRole('button', { name: 'View GitHub in Launch pod' })).not.toBeInTheDocument(); + expect(within(github).getByRole('button', { name: 'View GitHub in Ops' })).toBeInTheDocument(); + expect(within(github).getByRole('button', { name: 'Add' })).toBeInTheDocument(); + }); + + it('the granted entry itself leaves the offer, the other one stays', async () => { + mockTools({ entries: [githubEntry, linearEntry], grants: [grantLive, grantRevoked] }); + renderPage(); + + await screen.findByRole('img', { name: 'Granted' }); + expect(tileNode('GitHub').querySelector('.v2-cx-tile__dot')).not.toBeNull(); + expect(tileNode('Linear').querySelector('.v2-cx-tile__dot')).toBeNull(); + const github = await openTool('GitHub'); + expect(within(github).queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + const linear = await openToolPopover('Linear'); + expect(linear.textContent).toContain('Issues in Linear.'); + expect(linear.textContent).not.toContain('GitHub'); + }); + + it('with every entry granted the head carries no available count', async () => { + mockTools({ entries: [githubEntry, linearEntry], grants: [grantLive, grantLinear, grantRevoked] }); + renderPage(); + + await waitFor(() => expect(dots()).toHaveLength(2)); + expect(meta()).toHaveTextContent('2 on · 0 available'); + }); + }); + + it('Linear exposes only its pinned read mode in its offer and Add form', async () => { + mockTools({ entries: [githubEntry, linearEntry], grants: [grantLive] }); + renderPage(); + + const popover = await openToolPopover('Linear'); + expect(popover).toHaveTextContent('read'); + expect(popover).not.toHaveTextContent('read, or read and write'); + + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + const modeGroup = within(form).getByRole('group', { name: 'what it may do' }); + expect(within(modeGroup).getAllByRole('button')).toHaveLength(1); + expect(within(modeGroup).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('search filters tiles by tool name, and says so when nothing matches', async () => { + renderPage(); + await findTile('GitHub'); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { target: { value: 'comment' } }); + expect(tileNode('GitHub')).not.toBeNull(); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { target: { value: 'zzz' } }); + expect(tileNode('GitHub')).toBeNull(); + expect(screen.getByText('Nothing matches.')).toBeInTheDocument(); + }); + + it('with no grants and no catalogue the Tools group holds no tile and no row', async () => { + mockApi({ installables: [], grants: [], pods }); + renderPage(); + + const tools = await screen.findByRole('region', { name: 'Tools' }); + await settle(); + expect(tools.querySelector('.v2-cx-tile')).toBeNull(); + expect(document.querySelector('.v2-connector-row')).toBeNull(); + expect(meta()).toHaveTextContent('0 on · 0 available'); + }); + + /** + * Scope §4, step 7's named test: "the page's call to start is credentialed". + * + * The start route binds a connect to the browser that began it with an httpOnly + * cookie on its OWN response, and the API is a different origin from the app. + * Without `withCredentials` the browser drops the `Set-Cookie`, so a person + * consents at the vendor and comes back to a row that is still not connected, + * every callback refusing `invalid_state`. A backend suite sets the cookie itself + * and cannot witness this flag; this arm is the one that can. + */ + it('step 7: the page\'s call to start a hosted connect is credentialed', async () => { + const hosted = { ...linearEntry, description: 'Issues and projects in Linear.', connections: [] }; + mockTools({ entries: [hosted], grants: [] }); + axios.post.mockResolvedValue({ data: { authorizeUrl: 'https://mcp.linear.app/authorize?state=s' } }); + const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() }; + jest.spyOn(window, 'open').mockReturnValue(opened); + renderPage(); + + const popover = await openToolPopover('Linear'); + fireEvent.click(within(popover).getByRole('button', { name: 'Connect' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith( + '/api/integrations/connect/hosted-mcp/linear/start', + {}, + expect.objectContaining({ withCredentials: true }), + )); + expect(opened.location.assign).toHaveBeenCalledWith('https://mcp.linear.app/authorize?state=s'); + }); + + it('a hosted connector shows vendor setup guidance before connect', async () => { + mockTools({ entries: [{ ...linearEntry, connections: [], connectNotice: 'Ask your admin to allow this domain.' }], grants: [] }); + renderPage(); + + const popover = await openToolPopover('Linear'); + expect(within(popover).getByText('Ask your admin to allow this domain.')).toBeInTheDocument(); + expect(within(popover).getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + }); + + // TASK-290: GitHub's App user token reaches only the repositories the App is + // installed on, so the page that picks them sits beside Connect and stays after + // connecting, while the person decides what to grant. + it('a hosted connector links to where the person chooses its reach, before and after connecting', async () => { + const accessPage = 'https://github.com/apps/commonly-connect/installations/new'; + mockTools({ entries: [{ ...linearEntry, connections: [], accessPage }], grants: [] }); + const first = renderPage(); + const popover = await openToolPopover('Linear'); + const before = within(popover).getByRole('link', { name: 'Choose what Linear can reach' }); + expect(before).toHaveAttribute('href', accessPage); + expect(before).toHaveAttribute('target', '_blank'); + expect(before).toHaveAttribute('rel', 'noopener noreferrer'); + expect(within(popover).getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + first.unmount(); + + mockTools({ entries: [{ ...linearEntry, accessPage }], grants: [] }); + renderPage(); + const after = await openToolPopover('Linear'); + expect(within(after).getByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage); + expect(within(after).queryByRole('button', { name: 'Connect' })).toBeNull(); + }); + + // A live grant takes the entry off the offer, so the page moves to the grant's + // details. Only the granter, who owns the connection, is sent there. + it('a live hosted grant keeps the reach link on its details for the granter only', async () => { + const accessPage = 'https://github.com/apps/commonly-connect/installations/new'; + const entry = { ...linearEntry, accessPage }; + mockTools({ entries: [entry], grants: [grantLinear] }); + const first = renderPage(); + const { aside } = await openRow('Linear', 'View Linear in Launch pod'); + expect(within(aside).getByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage); + first.unmount(); + + mockTools({ entries: [entry], grants: [{ ...grantLinear, grantedBy: 'u2' }] }); + renderPage(); + const { aside: other } = await openRow('Linear', 'View Linear in Launch pod'); + expect(within(other).queryByRole('link', { name: /can reach/ })).toBeNull(); + }); + + it('a hosted connector without an access page offers no reach link', async () => { + mockTools({ entries: [{ ...linearEntry, connections: [] }], grants: [] }); + renderPage(); + + const popover = await openToolPopover('Linear'); + expect(within(popover).getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /can reach/ })).toBeNull(); + }); + + it('a hidden hosted entry has no offering while its existing connection can be removed', async () => { + mockTools({ + entries: [], + grants: [], + hidden: [{ connectionId: 'conn-hidden', entryId: 'airtable', label: 'Airtable', status: 'connected', revokePage: 'https://airtable.com/account' }], + }); + axios.delete.mockResolvedValue({ data: { message: 'removed' } }); + renderPage(); + + await settle(); + await expandOff('Tools'); + expect(await screen.findByText('Airtable')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Remove connection' })); + fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-hidden', expect.anything())); + expect(await screen.findByText(/Connection removed. Review its access at Airtable/)).toBeInTheDocument(); + }); + + it('a hosted connect start error uses fixed copy instead of response text', async () => { + mockTools({ entries: [{ ...linearEntry, connections: [] }], grants: [] }); + axios.post.mockRejectedValue({ + response: { data: { error: 'client_not_configured', message: 'spoofed vendor text' } }, + }); + const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() }; + jest.spyOn(window, 'open').mockReturnValue(opened); + renderPage(); + + const popover = await openToolPopover('Linear'); + fireEvent.click(within(popover).getByRole('button', { name: 'Connect' })); + expect(await screen.findByRole('alert')).toHaveTextContent( + 'This connector’s sign-in setup needs attention. Ask an administrator to check it.', + ); + expect(screen.queryByText(/spoofed vendor text/)).toBeNull(); + expect(opened.close).toHaveBeenCalled(); + }); + + it.each([400, 403])('a hosted grant HTTP %s stays open with the server refusal', async (status) => { + mockTools({ entries: [linearEntry], grants: [] }); + axios.post.mockRejectedValue({ response: { status, data: { + error: 'invalid_target', message: 'The selected target is unavailable.', + } } }); + renderPage(); + + const popover = await openToolPopover('Linear'); + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + expect(await within(form).findByRole('alert')).toHaveTextContent('The selected target is unavailable.'); + expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument(); + expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ + connectionId: 'conn-2', target: { kind: 'pod', id: 'p1' }, audience: ['a1'], + }), expect.any(Object)); + }); + + it.each([undefined, {}, { error: 'invalid_target' }, 'fallback', { grantId: '' }, { grantId: ' ' }])( + 'an unconfirmed mint response keeps the form and old grant: %p', async (body) => { + axios.post.mockResolvedValue({ data: body }); + renderPage(); + + const { aside } = await openRow('GitHub', 'View GitHub in Launch pod'); + fireEvent.click(within(aside).getByRole('button', { name: 'Change access' })); + const form = await screen.findByRole('complementary', { name: 'Change access' }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + expect(await within(form).findByRole('alert')).toHaveTextContent('Could not grant it.'); + expect(screen.getByRole('complementary', { name: 'Change access' })).toBeInTheDocument(); + expect(axios.post.mock.calls.map(([url]) => url)).toEqual(['/api/grants']); + }, + ); + + it.each(['unavailable', 'removed'])('a catalogue refresh (%s) cannot hide a refused hosted grant draft', async (refresh) => { + mockTools({ entries: [linearEntry], grants: [] }); + const message = 'Agents can self-install into this pod. Make it invite-only to use this connector.'; + axios.post.mockRejectedValue({ response: { status: 403, data: { error: 'outsider_grant_pod_joinable', message } } }); + renderPage(); + + const popover = await openToolPopover('Linear'); + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + expect(await within(form).findByRole('alert')).toHaveTextContent(message); + const previousGet = axios.get.getMockImplementation(); + axios.get.mockImplementation((url, ...args) => (url === '/api/installables' + ? (refresh === 'unavailable' ? Promise.reject(new Error('offline')) : Promise.resolve({ data: { installables: [] } })) + : previousGet(url, ...args))); + const reads = axios.get.mock.calls.length; + await act(async () => { document.dispatchEvent(new Event('visibilitychange')); }); + await waitFor(() => expect(axios.get.mock.calls.length).toBeGreaterThan(reads)); + expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent(message); + await waitFor(() => expect(meta()).toHaveTextContent('0 on')); + }); + + it('hosted grant picker sends the selected pod ID and keeps the agent User ID in audience', async () => { + mockTools({ + entries: [linearEntry], + grants: [], + seats: { p1: [scout], p2: [{ name: 'reed', userId: 'a2' }] }, + }); + renderPage(); + + const popover = await openToolPopover('Linear'); + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + fireEvent.change(within(form).getByRole('combobox', { name: 'pod' }), { target: { value: 'p2' } }); + fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ + target: { kind: 'pod', id: 'p2' }, audience: ['a2'], + }), expect.anything())); + }); + + it('the dialog cannot submit an empty audience and re-enables grant when an agent is selected', async () => { + mockTools({ entries: [linearEntry], grants: [] }); + renderPage(); + + const popover = await openToolPopover('Linear'); + fireEvent.click(within(popover).getByRole('button', { name: 'Add' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + const submit = within(form).getByRole('button', { name: 'Grant' }); + expect(submit).toBeEnabled(); + fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' })); + expect(within(form).getByRole('status')).toHaveTextContent('Select at least one agent to grant access.'); + expect(submit).toBeDisabled(); + fireEvent.click(submit); + expect(axios.post).not.toHaveBeenCalled(); + fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' })); + expect(submit).toBeEnabled(); + fireEvent.click(submit); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'] }), expect.anything())); + }); + + it('Grant again on an existing empty-audience row requires an explicit new selection', async () => { + mockTools({ entries: [linearEntry], grants: [{ ...grantRevoked, tools: ['linear.list_issues'], effectiveAudience: [] }] }); + renderPage(); + + const drawer = await openTool('Linear'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' })); + const form = await screen.findByRole('complementary', { name: 'Add Linear' }); + const agent = within(form).getByRole('checkbox', { name: 'Scout' }); + expect(agent).not.toBeChecked(); + const submit = within(form).getByRole('button', { name: 'Grant' }); + expect(submit).toBeDisabled(); + fireEvent.click(submit); + expect(axios.post).not.toHaveBeenCalled(); + fireEvent.click(agent); + fireEvent.click(submit); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'], target: { kind: 'pod', id: 'p2' } }), expect.anything())); + }); + + it.each([ + { label: 'human-only', audience: ['u1'], selected: false }, + { label: 'mixed human and agent', audience: ['u1', 'a1'], selected: true }, + ])('Grant again restores only current agents from a $label legacy audience', async ({ audience, selected }) => { + mockTools({ grants: [{ ...grantRevoked, effectiveAudience: audience }] }); + renderPage(); + + const drawer = await openTool('GitHub'); + fireEvent.click(within(drawer).getByRole('button', { name: 'Grant again' })); + const form = await screen.findByRole('complementary', { name: 'Add GitHub' }); + const scoutBox = within(form).getByRole('checkbox', { name: 'Scout' }); + const submit = within(form).getByRole('button', { name: 'Grant' }); + expect(scoutBox.checked).toBe(selected); + if (!selected) { + expect(submit).toBeDisabled(); + expect(within(form).getByText('Select at least one agent to grant access.')).toBeInTheDocument(); + fireEvent.click(submit); + expect(axios.post).not.toHaveBeenCalled(); + fireEvent.click(scoutBox); + } + expect(submit).toBeEnabled(); + fireEvent.click(submit); + await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ + target: { kind: 'pod', id: 'p2' }, audience: ['a1'], + }), expect.anything())); + }); + + it('a tool popover shows the zh-CN catalog copy, and an unmapped entry keeps the server text', async () => { + const linear = { + installableId: 'linear', list: 'tools', label: 'Linear', description: 'Read Linear issues and projects.', + connectionType: 'hosted-mcp', entryId: 'linear', available: true, tools: [], connections: [], installation: null, integration: null, + }; + mockTools({ + entries: [linear, { ...linear, installableId: 'acme-tools', label: 'Acme', description: 'Acme server copy.', entryId: 'acme' }], + grants: [], + }); + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + renderPage(); + const mapped = await openToolPopover('Linear'); + expect(within(mapped).getByText('你自己的 Linear 工作空间中的议题、项目、周期和发布。')).toBeInTheDocument(); + const unmapped = await openToolPopover('Acme'); + expect(within(unmapped).getByText('Acme server copy.')).toBeInTheDocument(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + it('the open grant form reads in zh-CN, not English', async () => { + await i18nReady; + await act(async () => { await i18n.changeLanguage('zh-CN'); }); + try { + mockTools({ grants: [grantRevoked], trails: {} }); + renderPage(); + const drawer = await openTool('GitHub'); + fireEvent.click(await within(drawer).findByRole('button', { name: /^添加$|^Add$/ })); + const form = await screen.findByRole('complementary'); + expect(within(form).getByText('无需确认')).toBeInTheDocument(); + expect(within(form).queryByText('nothing asks first')).toBeNull(); + expect(within(form).queryByText('read and write, ask first')).toBeNull(); + // No English button left in the zh-CN form. + expect(within(form).queryByRole('button', { name: 'Grant' })).toBeNull(); + } finally { + await act(async () => { await i18n.changeLanguage('en'); }); + } + }); + + // The old inactive fold (retired fold tests): the roster of ended grants no + // longer hides behind a closed fold. Never-granted entries are tiles, and the ended + // grants of one entry sit under an "inactive · N" heading in that entry's drawer. + describe('ended grants and never-granted offers', () => { + const base = { + grantId: 'g', installationId: 'inst-github', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues'], + writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: iso(86_400_000), + revokedAt: null, revokedBy: null, parentGrantId: null, rootGrantId: null, createdAt: iso(-86_400_000), grantedBy: 'u1', + }; + const grant = (id, revokedAt) => ({ ...base, grantId: id, revokedAt, revokedBy: revokedAt ? 'u1' : null }); + const expired = (id) => ({ ...base, grantId: id, expiresAt: iso(-86_400_000) }); + const yesterday = iso(-86_400_000); + const offers = ['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry'].map((label) => ({ + ...githubEntry, installableId: label.toLowerCase(), label, entryId: label.toLowerCase(), + tools: [{ name: `${label.toLowerCase()}.list_issues`, requiredWriteMode: 'read', irreversible: false }], + })); + const onePod = [{ _id: 'p1', name: 'Launch pod', type: 'chat', members: [{ _id: 'u1', username: 'sam' }] }]; + const toolTiles = () => Array.from(screen.getByRole('region', { name: 'Tools' }).querySelectorAll('.v2-cx-tile__name')).map((node) => node.textContent); + + it('five never-granted catalogue offers are five tiles, with no inactive heading', async () => { + mockTools({ entries: offers, grants: [], podList: onePod, seats: {} }); + renderPage(); + + await findTile('Sentry'); + expect(toolTiles()).toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']); + await waitFor(() => expect(meta()).toHaveTextContent('0 on · 5 available')); + expect(screen.queryByText(/inactive ·/)).not.toBeInTheDocument(); + expect(screen.queryByText('Nothing matches.')).not.toBeInTheDocument(); + }); + + it('eight ended grants stay out of the five offer tiles and list under one heading in their entry drawer', async () => { + const grants = [ + ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)), + ...Array.from({ length: 4 }, (_, i) => expired(`expired-${i}`)), + ]; + mockTools({ entries: offers, grants, podList: onePod, seats: {} }); + renderPage(); + + await findTile('Sentry'); + await settle(); + expect(toolTiles()).toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']); + expect(document.querySelectorAll('.v2-connector-row')).toHaveLength(0); + const drawer = await openTool('GitHub'); + expect(within(drawer).getByText('inactive · 8')).toBeInTheDocument(); + expect(drawer.querySelectorAll('.v2-connector-row')).toHaveLength(8); + }); + + it('two live grants come first and nine ended ones follow under "inactive · 9"', async () => { + const grants = [ + grant('live-1', null), grant('live-2', null), + ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)), + ...Array.from({ length: 5 }, (_, i) => expired(`expired-${i}`)), + ]; + mockTools({ grants, entries: [githubEntry], podList: onePod, seats: {} }); + renderPage(); + + const drawer = await openTool('GitHub'); + const rows = Array.from(drawer.querySelectorAll('.v2-connector-row')); + expect(rows).toHaveLength(11); + expect(rows.slice(0, 2).every((row) => !row.classList.contains('v2-connector-row--dead'))).toBe(true); + expect(rows.slice(2).every((row) => row.classList.contains('v2-connector-row--dead'))).toBe(true); + expect(within(drawer).getByText('inactive · 9')).toBeInTheDocument(); + expect(screen.queryByText(/not enabled|not yet|coming/i)).not.toBeInTheDocument(); + }); + + it('search keeps a matching offer and drops the hidden connection that does not match', async () => { + const linear = { + ...githubEntry, installableId: 'linear', connectionType: 'hosted-mcp', entryId: 'linear', label: 'Linear', description: 'Linear issues', + tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }], + }; + mockTools({ + entries: [githubEntry, linear], + grants: [grant('live', null), grant('revoked', yesterday)], + hidden: [{ connectionId: 'hidden-1', entryId: 'atlassian', label: 'Atlassian', status: 'inactive', revokePage: null }], + podList: onePod, + seats: {}, + }); + renderPage(); + + expect(await screen.findByRole('button', { name: '+1 off' })).toBeInTheDocument(); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search' }), { target: { value: 'Linear' } }); + expect(screen.queryByRole('button', { name: /^\+\d+ off$/ })).not.toBeInTheDocument(); + expect(toolTiles()).toEqual(['Linear']); + expect(document.body).not.toHaveTextContent('Atlassian'); + }); + }); +}); diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx deleted file mode 100644 index b85f038367..0000000000 --- a/frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import V2ConnectorTools from '../components/V2ConnectorTools'; -import { AuthContext } from '../../context/AuthContext'; -import i18n, { i18nReady } from '../../i18n'; - -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 auth = { - currentUser: { _id: 'u1', username: 'sam', email: 'sam@example.test' }, user: { _id: 'u1', username: 'sam', email: 'sam@example.test' }, token: 'user-jwt', - loading: false, error: null, isAuthenticated: true, register: jest.fn(), login: jest.fn(), signInWithToken: jest.fn(), logout: jest.fn(), updateProfile: jest.fn(), -}; -const tomorrow = new Date(Date.now() + 86_400_000).toISOString(); -const yesterday = new Date(Date.now() - 86_400_000).toISOString(); -const grant = (id: string, revokedAt: string | null) => ({ - grantId: id, installationId: 'inst-github', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues'], - writeMode: 'read', budget: null, effectiveAudience: [], expiresAt: tomorrow, - revokedAt, revokedBy: revokedAt ? 'u1' : null, parentGrantId: null, rootGrantId: null, - createdAt: yesterday, grantedBy: 'u1', -}); -const catalog = [{ installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues', available: true, - tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }], connections: [] }]; -const offers = ['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry'].map((label) => ({ - ...catalog[0], installableId: label.toLowerCase(), label, - tools: [{ name: `${label.toLowerCase()}.list_issues`, requiredWriteMode: 'read', irreversible: false }], -})); - -const mockTools = (entries: typeof catalog, grants: ReturnType[]) => { - axios.get.mockImplementation((url: string) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: entries } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); -}; - -const renderTools = () => render( - -); - -beforeAll(async () => { await i18nReady; await i18n.changeLanguage('en'); }); - -test('five never-granted catalogue offers are visible with no inactive fold', async () => { - mockTools(offers, []); - const { container } = renderTools(); - - await waitFor(() => expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(5)); - expect(screen.getByText('0 granted · 5 more')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /inactive ·/ })).not.toBeInTheDocument(); - expect([...container.querySelectorAll('.v2-connector-row__name')].map((row) => row.textContent)) - .toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']); - expect(screen.queryByText('Nothing matches.')).not.toBeInTheDocument(); -}); - -test('eight ended grants fold below five never-granted offers', async () => { - const grants = [ - ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)), - ...Array.from({ length: 4 }, (_, i) => ({ ...grant(`expired-${i}`, null), expiresAt: yesterday })), - ]; - mockTools(offers, grants); - const { container } = renderTools(); - - const fold = await screen.findByRole('button', { name: 'inactive · 8' }); - expect(fold).toHaveAttribute('aria-expanded', 'false'); - expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(5); - expect([...container.querySelectorAll('.v2-connector-row__name')].map((row) => row.textContent)) - .toEqual(['GitHub', 'Linear', 'Atlassian', 'Airtable', 'Sentry']); - fireEvent.click(fold); - expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(13); -}); - -test('two live grants stay visible and nine revoked or expired grants start in one closed fold', async () => { - const grants = [grant('live-1', null), grant('live-2', null), - ...Array.from({ length: 4 }, (_, i) => grant(`revoked-${i}`, yesterday)), - ...Array.from({ length: 5 }, (_, i) => ({ ...grant(`expired-${i}`, null), expiresAt: yesterday }))]; - axios.get.mockImplementation((url: string) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: catalog } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - - const { container } = render( - - ); - - const fold = await screen.findByRole('button', { name: 'inactive · 9' }); - expect(fold).toHaveAttribute('aria-expanded', 'false'); - expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(2); - expect(screen.queryByText(/not enabled|not yet|coming/i)).not.toBeInTheDocument(); - - fireEvent.click(fold); - await waitFor(() => expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(11)); - expect(fold).toHaveAttribute('aria-expanded', 'true'); -}); - -test('search keeps a matching catalogue offer visible and folds only ended connections', async () => { - const entries = [catalog[0], { ...catalog[0], installableId: 'linear', connectionType: 'hosted-mcp', entryId: 'linear', label: 'Linear', description: 'Linear issues', - tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }] }]; - axios.get.mockImplementation((url: string) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: entries, hiddenToolConnections: [ - { connectionId: 'hidden-1', entryId: 'atlassian', label: 'Atlassian', status: 'inactive', revokePage: null }, - ] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [grant('live', null), grant('revoked', yesterday)] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - - const { container } = render( - - ); - expect(await screen.findByRole('button', { name: 'inactive · 2' })).toBeInTheDocument(); - - fireEvent.change(screen.getByRole('searchbox', { name: 'Search tools' }), { target: { value: 'Linear' } }); - expect(screen.queryByRole('button', { name: /inactive ·/ })).not.toBeInTheDocument(); - expect(container.querySelectorAll('.v2-connector-row')).toHaveLength(1); - expect(container.querySelector('.v2-connector-row')).toHaveTextContent('Linear'); - expect(container).not.toHaveTextContent('Atlassian'); - expect(container.querySelector('.v2-connector-row__name')).toHaveTextContent('Linear'); -}); diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx deleted file mode 100644 index 50024d7a78..0000000000 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ /dev/null @@ -1,1236 +0,0 @@ -// @ts-nocheck -// Tools list (tools plan §6): rows from the projected grants, the aside from the -// grant, the trail and its three counts from the broker's rows. Nothing here -// draws a control the server does not enforce. -import React from 'react'; -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import V2ConnectorTools from '../components/V2ConnectorTools'; -import { AuthContext } from '../../context/AuthContext'; -import i18n, { i18nReady } from '../../i18n'; - -// These existing cases exercise grant actions and row copy with the roster -// expanded. The collapsed interaction is covered by V2ConnectorTools.fold. -jest.mock('../components/V2InactiveFold', () => ({ - __esModule: true, - default: ({ children }) => <>{children}, -})); - -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 NOW = Date.now(); -const iso = (ms) => new Date(NOW + ms).toISOString(); -const pods = [ - { _id: 'p1', name: 'Launch pod', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] }, - { _id: 'p2', name: 'Ops', members: [{ _id: 'u1', username: 'sam' }, { _id: 'a1', username: 'scout' }] }, -]; -const grantLive = { - grantId: 'grant_live', installationId: 'inst-1', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues', 'github.comment_on_issue'], - writeMode: 'write-with-confirm', budget: { calls: 50, windowMs: 3600000 }, effectiveAudience: ['a1'], expiresAt: iso(6 * 86400000), - revokedAt: null, revokedBy: null, parentGrantId: null, rootGrantId: null, createdAt: iso(-3600000), grantedBy: 'u1', -}; -const grantRevoked = { ...grantLive, grantId: 'grant_gone', target: { kind: 'pod', id: 'p2' }, writeMode: 'read', effectiveAudience: ['a1'], revokedAt: iso(-600000), revokedBy: 'u1', createdAt: iso(-86400000) }; -const trail = { - grantId: 'grant_live', - calls: [ - { callId: 'c1', agentUserId: 'a1', tool: 'github.list_issues', outcome: 'ok', reason: null, approvalId: null, argsDigest: 'a'.repeat(64), at: iso(-120000), durationMs: 80 }, - { callId: 'c2', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'refused', reason: 'not_in_audience', approvalId: null, argsDigest: 'b'.repeat(64), at: iso(-300000), durationMs: null }, - { callId: 'c3', agentUserId: 'a1', tool: 'github.comment_on_issue', outcome: 'pending_approval', reason: null, approvalId: 'appr-1', argsDigest: 'c'.repeat(64), at: iso(-400000), durationMs: null }, - // C4-11: the ask was answered and the call ran as the row above it; the - // server reports the parked row as `superseded`, never as still awaiting. - { callId: 'c4', agentUserId: 'a1', tool: 'github.close_issue', outcome: 'superseded', reason: null, approvalId: 'appr-2', argsDigest: 'd'.repeat(64), at: iso(-500000), durationMs: null }, - ], - counts: { total: 3, ok: 1, refused: 1, pending_approval: 1, failed: 0 }, -}; - -const githubEntry = { - installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues and pull requests.', available: true, - broker: { id: 'commonly-grant-broker' }, - tools: [ - { name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }, - { name: 'github.comment_on_issue', requiredWriteMode: 'write', irreversible: true }, - { name: 'github.close_issue', requiredWriteMode: 'write', irreversible: true }, - ], - connections: [{ connectionId: 'conn-1', owner: 'Team-Commonly', repo: 'commonly' }], -}; - -const linearEntry = { - installableId: 'linear', list: 'tools', label: 'Linear', description: 'Issues in Linear.', available: true, - connectionType: 'hosted-mcp', entryId: 'linear', - broker: { id: 'commonly-grant-broker' }, - tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }], - connections: [{ connectionId: 'conn-2', owner: 'Team-Commonly', repo: 'linear' }], -}; -const grantLinear = { ...grantLive, grantId: 'grant_lin', installationId: 'inst-2', writeMode: 'read', tools: ['linear.list_issues'] }; - -test('Atlassian shows its read-in-testing state on an existing grant', async () => { - const atlassian = { ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian', - tools: [{ name: 'atlassian.get_accessible_atlassian_resources', requiredWriteMode: 'read', irreversible: false }] }; - const grant = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.get_accessible_atlassian_resources'] }; - mockTwoEntries({ catalog: [atlassian], grants: { p1: [grant], p2: [] } }); - render(); - expect(await screen.findByText(/read in testing/)).toBeInTheDocument(); -}); - -// Two tools entries and one grant: the shape the page has to survive once a -// second tool Installable ships. The old single-entry accounting hid the entry -// it did not cover (and the header still subtracted exactly one). -const mockTwoEntries = ({ catalog = [githubEntry, linearEntry], grants = { p1: [grantLinear], p2: [grantRevoked] } } = {}) => { - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: catalog } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: Object.values(grants).flat() } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); -}; - -const mockApi = (catalog = []) => { - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [{ installableId: 'telegram', list: 'channels' }, ...catalog] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive, grantRevoked] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url === '/api/grants/grant_live/calls') return Promise.resolve({ data: trail }); - if (url === '/api/grants/grant_gone/calls') return Promise.resolve({ data: { grantId: 'grant_gone', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } }); -}; - -const renderTools = (props = {}) => render( - - - , -); - -beforeEach(() => { jest.clearAllMocks(); mockApi([githubEntry]); }); - -test('one owner grant read shows the 21st pod and keeps revoked and expired labels', async () => { - const manyPods = Array.from({ length: 21 }, (_, index) => ({ - _id: `p${index + 1}`, name: `Pod ${index + 1}`, members: [{ _id: 'u1', username: 'sam' }], - })); - const inLastPod = { ...grantLive, grantId: 'grant_21', target: { kind: 'pod', id: 'p21' } }; - const revoked = { ...inLastPod, grantId: 'grant_21_revoked', revokedAt: iso(-600000), revokedBy: 'u1' }; - const expired = { ...inLastPod, grantId: 'grant_21_expired', expiresAt: iso(-600000) }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [inLastPod, revoked, expired] } }); - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { calls: [], counts: {} } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - renderTools({ pods: manyPods }); - - expect(await screen.findAllByRole('button', { name: 'View GitHub in Pod 21' })).toHaveLength(3); - expect(screen.getByText(/revoked by sam 10m/)).toBeInTheDocument(); - expect(screen.getByText(/expired 10m/)).toBeInTheDocument(); - fireEvent.click(screen.getAllByRole('button', { name: 'Grant again' })[0]); - expect(await screen.findByRole('option', { name: 'Pod 21' })).toBeInTheDocument(); - const grantReads = axios.get.mock.calls.filter(([url]) => url === '/api/grants'); - expect(grantReads).toHaveLength(1); - expect(grantReads[0][1]).toMatchObject({ params: { connectionOwner: 'me' } }); - expect(axios.get.mock.calls.some(([url]) => /\/api\/pods\/.*\/grants/.test(url))).toBe(false); -}); - -test('a sole join-policy refusal offers the owner an in-place fix and then allows retry', async () => { - axios.post.mockRejectedValueOnce({ response: { data: { - error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.', - } } }).mockResolvedValueOnce({ data: { grantId: 'grant_new' } }); - axios.patch.mockResolvedValue({ data: { id: 'p2', joinPolicy: 'invite-only' } }); - renderTools({ pods: [pods[0], { ...pods[1], createdBy: { _id: 'u1' } }] }); - fireEvent.click(await screen.findByRole('button', { name: 'Grant again' })); - const aside = await screen.findByRole('complementary', { name: 'Add GitHub' }); - fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); - fireEvent.click(await within(aside).findByRole('button', { name: 'Make this pod invite-only' })); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith('/api/pods/p2/join-policy', { joinPolicy: 'invite-only' }, expect.anything())); - expect(await within(aside).findByText('This pod is invite-only. Select Grant to continue.')).toBeInTheDocument(); - fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); -}); - -test('the in-place fix is not offered to a non-owner', async () => { - axios.post.mockRejectedValueOnce({ response: { data: { - error: 'outsider_grant_pod_joinable', message: 'Agents can self-install into this pod. Make it invite-only to use this connector.', - } } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Grant again' })); - const aside = await screen.findByRole('complementary', { name: 'Add GitHub' }); - fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); - await within(aside).findByText('Agents can self-install into this pod. Make it invite-only to use this connector.'); - expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument(); -}); - -test('a bridged-pod refusal never offers the join-policy fix', async () => { - axios.post.mockRejectedValueOnce({ response: { data: { - error: 'outsider_grant_pod_bridged', message: 'Use a separate unbridged pod for this connector.', - } } }); - renderTools({ pods: [pods[0], { ...pods[1], createdBy: 'u1' }] }); - fireEvent.click(await screen.findByRole('button', { name: 'Grant again' })); - const aside = await screen.findByRole('complementary', { name: 'Add GitHub' }); - fireEvent.click(within(aside).getByRole('button', { name: 'Grant' })); - await within(aside).findByText('Use a separate unbridged pod for this connector.'); - expect(within(aside).queryByRole('button', { name: 'Make this pod invite-only' })).not.toBeInTheDocument(); -}); - -test('TASK-131: a grant age advances in place, and a returning tab re-reads, without a reload', async () => { - jest.useFakeTimers(); - try { - renderTools(); - expect(await screen.findByText(/granted 1h$/)).toBeInTheDocument(); - - await act(async () => { jest.advanceTimersByTime(60 * 60_000); }); - expect(screen.getByText(/granted 2h$/)).toBeInTheDocument(); - - const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/grants').length; - const before = reads(); - document.dispatchEvent(new Event('visibilitychange')); - await waitFor(() => expect(reads()).toBeGreaterThan(before)); - } finally { - jest.useRealTimers(); - } -}); - -test('TASK-131: a grant expiring with the page open moves its tool back to Not yet on the same tick as the count', async () => { - jest.useFakeTimers(); - try { - // Live for one more minute, so the only thing that changes is the clock. - const expiring = { ...grantLive, grantId: 'grant_soon', expiresAt: iso(60_000) }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [expiring] } }); - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_soon', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - renderTools(); - expect(await screen.findByText('1 granted')).toBeInTheDocument(); - expect(screen.queryByText('not granted')).not.toBeInTheDocument(); - - await act(async () => { jest.advanceTimersByTime(2 * 60_000); }); - - // The count always followed the clock; the list did not, so the two - // disagreed on screen until a reload. - expect(screen.getByText('0 granted · 1 more')).toBeInTheDocument(); - expect(screen.getByText('not granted')).toBeInTheDocument(); - } finally { - jest.useRealTimers(); - } -}); - -test('rows carry the states table: a live grant pulses when used in the last 10 minutes, a revoked one goes hollow', async () => { - renderTools(); - expect(await screen.findByRole('heading', { name: 'Tools' })).toBeInTheDocument(); - expect(screen.getByText('1 granted')).toBeInTheDocument(); - const live = screen.getByRole('button', { name: 'View GitHub in Launch pod' }); - expect(within(live).getByText('Launch pod')).toBeInTheDocument(); - expect(within(live).getByText('sam')).toBeInTheDocument(); - // Direction A: the audience is the sentence; the write mode is the glyph (label) and rides the 390 kicker as words. - expect(within(live).getByText('Scout may use it')).toBeInTheDocument(); - expect(within(live).getByRole('img', { name: /asks|write/ })).toBeInTheDocument(); - expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first'); - expect(within(live).getByText(/granted 1h$/)).toBeInTheDocument(); - const liveDot = live.querySelector('.v2-connector-row__dot'); - expect(liveDot).toHaveClass('v2-connector-row__dot--live'); - await waitFor(() => expect(liveDot).toHaveClass('v2-connector-row__dot--pulse')); - const gone = screen.getByRole('button', { name: 'View GitHub in Ops' }); - expect(within(gone).getByText('revoked by sam 10m ago')).toBeInTheDocument(); - expect(gone.closest('.v2-connector-row')).toHaveClass('v2-connector-row--dead'); - expect(gone.querySelector('.v2-connector-row__dot')).toHaveClass('v2-connector-row__dot--empty'); - // Every catalogue entry is granted, so there is no not-yet row or Add action. - expect(screen.queryByText('not granted')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Manage' })).toHaveLength(1); - expect(screen.getByRole('button', { name: 'Grant again' })).toBeInTheDocument(); - // Direction A: Manage is the gear (word in title + aria-label), and the age rides the kicker. - for (const manage of screen.getAllByRole('button', { name: 'Manage' })) { - expect(manage).toHaveClass('v2-connector-row__action--icon'); - expect(manage.textContent).toBe(''); - } - expect(document.querySelector('.v2-connector-row__when')).toBeNull(); - expect(document.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/ · granted /); -}); - -// TASK-164: every {{rel}} in these lines used to be filled with an English -// relative time, so a zh grant read "Granted by sam 1小时前." worst of both. -// TASK-649: one timestamp pair, two ages. A grant made 16 minutes before its -// revoke and read 41h later must say the same day count for both lines, not -// "granted 1d" beside "revoked 2d ago". -test('TASK-649: the grant age and the revoke age round the same way on one row', async () => { - const createdAt = iso(-(41 * 3600_000 + 24 * 60_000)); - const revokedAt = iso(-(41 * 3600_000 + 8 * 60_000)); - const grant = { ...grantRevoked, createdAt, revokedAt, revokedBy: 'u1' }; - mockTwoEntries({ grants: { p1: [], p2: [grant] } }); - renderTools(); - const gone = await screen.findByRole('button', { name: 'View GitHub in Ops' }); - expect(within(gone).getByText(/granted 2d$/)).toBeInTheDocument(); - expect(within(gone).getByText('revoked by sam 2d ago')).toBeInTheDocument(); -}); - -// TASK-649: the same rule holds below a day. A grant's own age must round the -// way the revoke line rounds, at the minute and hour boundaries too. -test.each([ - [5 * 60_000 + 40_000, 'granted 6m'], - [100 * 60_000, 'granted 2h'], -])('TASK-649: a grant aged %p ms reads "%s" on its row', async (ageMs, expected) => { - const grant = { ...grantRevoked, createdAt: iso(-ageMs), revokedAt: null, revokedBy: null }; - mockTwoEntries({ grants: { p1: [], p2: [grant] } }); - renderTools(); - const row = await screen.findByRole('button', { name: 'View GitHub in Ops' }); - expect(within(row).getByText(new RegExp(`${expected}$`))).toBeInTheDocument(); -}); - -test('TASK-164: the grant, budget and trail times read in zh-CN, not English', async () => { - await i18nReady; - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - try { - const { container } = renderTools(); - await screen.findByText(/^Tools$|^工具$/); - fireEvent.click(container.querySelector('.v2-connector-row__selection') as HTMLElement); - const aside = await screen.findByRole('complementary'); - expect(within(aside).getByText(/前由 sam 授权。/)).toBeInTheDocument(); - expect(within(aside).getByText(/后结束。/)).toBeInTheDocument(); - // The TASK-180 ruling names this key explicitly — 「每{{window}}可调用 - // {{calls}}次」 — so the budget line loses both of its spaces. This assertion - // pinned the old spacing, which is why it had to move with the ruling. - expect(within(aside).getByText(/每1小时可调用50次/)).toBeInTheDocument(); - await waitFor(() => expect(within(aside).getAllByRole('listitem')).toHaveLength(4)); - const lines = within(aside).getAllByRole('listitem'); - expect(lines.map((line) => line.textContent)).toEqual([ - 'Scout · github.list_issues · 成功2分钟前', - 'Scout · github.comment_on_issue · 已拒绝5分钟前', - 'Scout · github.comment_on_issue · 等待人工确认7分钟前', - 'Scout · github.close_issue · 已回答8分钟前', - ]); - // The whole aside, not just the four line keys: this is the regression the - // gate caught — an English relative time inside an otherwise-Chinese line. - expect(aside.textContent).not.toMatch(/\bago\b|from now|\bm ago\b/); - } finally { - await act(async () => { await i18n.changeLanguage('en'); }); - } -}); - -test('TASK-179: the grant line is one sentence per language, and an empty audience is its own sentence', async () => { - // ux-lead's render at 3d6e1763 read the old three-piece composition as - // 「授权给 Growth 授权者 sam」, and the empty audience as 「没有智能体 可以使用」. - const emptyAudience = { ...grantLive, grantId: 'grant_empty', target: { kind: 'pod', id: 'p2' }, effectiveAudience: [] }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive, emptyAudience] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - renderTools(); - - const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); - const ops = await screen.findByRole('button', { name: 'View GitHub in Ops' }); - // One key holds both names, so the language orders them. en is unchanged from - // the three-piece version; zh puts the granter first, as the ruling requires. - expect(live.querySelector('strong')?.textContent).toBe('granted to Launch pod by sam'); - expect(within(ops).getByText('nobody may use it')).toBeInTheDocument(); - - fireEvent.click(live); - await screen.findByRole('complementary', { name: 'Grant details' }); - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - try { - // The accessible name is translated too, so locate rows by what they say. - const rows = () => Array.from(document.querySelectorAll('.v2-connector-row')) as HTMLElement[]; - const rowSaying = (text: string) => rows().find((row) => row.textContent?.includes(text)) as HTMLElement; - expect(rowSaying('Launch pod').querySelector('strong')?.textContent).toBe('由 sam 授权给 Launch pod'); - // The audience keeps its space before a Latin name, and loses it when there - // is no audience at all — which is why the empty case is a separate key. This - // is also the assertion that caught the first cut of TASK-180, where `agents` - // was declared a numeral by name and closed the space before the audience. - expect(rowSaying('Launch pod').textContent).toContain('Scout 可以使用'); - expect(rowSaying('Ops').textContent).toContain('没有智能体可以使用'); - // The separator key is zh-empty: two sentences that each end in 。 join with - // nothing, where the English pair needs the space. - const aside = document.querySelector('.v2-connectors__aside') as HTMLElement; - expect(within(aside).getByText(/授权。/).textContent).toBe('1小时前由 sam 授权。6天后结束。'); - } finally { - await act(async () => { await i18n.changeLanguage('en'); }); - } -}); - -test('TASK-179: a seat grant names the seat in its sentence and the pod in the row it sits in', async () => { - // ux-lead's gate at 07873c19: one shared label put the seat's name where the - // row's location belongs, announcing a seat grant under Ops as 「View GitHub in - // Reed」. The sentence's subject and the row's location are different nouns, so - // they are two expressions, and this test fails if they are ever re-merged. - const seatGrant = { ...grantLive, grantId: 'grant_seat', target: { kind: 'seat', id: 'a1' }, effectiveAudience: ['a1'] }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [seatGrant] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); - if (url === '/api/registry/pods/p2/agents') return Promise.resolve({ data: { agents: [{ name: 'reed', displayName: 'Reed', userId: 'a1' }] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - renderTools(); - - // Found by the pod's name: if the seat leaks back into the accessible name - // this query returns nothing, which is the fix's own control. - const row = await screen.findByRole('button', { name: 'View GitHub in Ops' }); - expect(row.querySelector('strong')?.textContent).toBe('granted to Reed by sam'); - expect(row.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/^Ops · granted /); -}); - -test('the aside reads the grant and the trail: agents, allow-list under its mode, what asks first, three counts, outcomes as words', async () => { - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(aside).getByRole('heading', { name: 'GitHub · Launch pod' })).toBeInTheDocument(); - expect(within(aside).getByText(/Granted by sam 1h ago\. Ends 6d from now\./)).toBeInTheDocument(); - expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('Scout'); - expect(within(aside).getByText('read and write, ask first').nextElementSibling).toHaveTextContent('github.list_issues'); - expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first'); - expect(within(aside).getByText('budget').nextElementSibling).toHaveTextContent('50 calls per 1h'); - await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3')); - expect(within(aside).getByText('refused').previousElementSibling).toHaveTextContent('1'); - expect(within(aside).getByText('awaiting a person').previousElementSibling).toHaveTextContent('1'); - const lines = within(aside).getAllByRole('listitem'); - expect(lines.map((line) => line.textContent)).toEqual([ - 'Scout · github.list_issues · ok2m ago', - 'Scout · github.comment_on_issue · refused5m ago', - 'Scout · github.comment_on_issue · awaiting a person7m ago', - 'Scout · github.close_issue · answered8m ago', - ]); - // A superseded request is not a call: the counts (which the server derives) - // still say 3 while the trail lists its 4 rows, and only the genuinely open - // one is counted as awaiting a person. - expect(lines.map((line) => line.textContent).filter((text) => text.includes('awaiting a person'))).toHaveLength(1); - // `answered` covers a resolution that was declined or expired as - // well as one that ran, so its mark must say SETTLED without saying approved — - // the sibling row beside it is the one that carries the verdict. Shield plus a - // short bar; the shield-and-check it shipped with read as "approved" next to a - // row that says `refused`. - const answeredLine = lines.find((line) => line.textContent?.includes('answered')); - expect(answeredLine?.querySelectorAll('svg path')).toHaveLength(2); - expect(Array.from(answeredLine?.querySelectorAll('svg path') || []).map((path) => path.getAttribute('d'))).toEqual([ - 'M12 3 4 6v6c0 5 3.4 8.4 8 9 4.6-.6 8-4 8-9V6z', - 'M9 12h6', - ]); - // The arguments never reach the page: only the digest does, and it is not rendered. - expect(aside.textContent).not.toContain('a'.repeat(64)); - expect(axios.get).toHaveBeenCalledWith('/api/grants/grant_live/calls', expect.objectContaining({ headers: expect.any(Object) })); -}); - -test('TASK-050: a human member in the audience is named, not called "an agent"', async () => { - // A pod grant's audience is a snapshot of the room's members, so a human id - // is in it; `seats` holds agents only, and the label for anything else said - // "an agent" — two humans read as "an agent, an agent, Scout". - const withPeople = { ...grantLive, effectiveAudience: ['u1', 'a1'] }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [withPeople] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.resolve({ data: {} }); - }); - renderTools(); - const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); - expect(within(live).getByText('sam, Scout may use it')).toBeInTheDocument(); - expect(live.querySelector('.v2-connector-row__kicker-mode')).toHaveTextContent('every write asks first'); - expect(screen.queryByText(/an agent/)).not.toBeInTheDocument(); - - fireEvent.click(live); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(aside).getByText('agents allowed').nextElementSibling).toHaveTextContent('sam, Scout'); -}); - -test('TASK-624: a per-user bot member with no roster row is named by its curated label, not its instance id', async () => { - // A per-user Scout is a pod member with no AgentInstallation row in the roster, so the - // audience id resolves through the pod member. Its username is the raw instance id. - const perUserPods = [{ - _id: 'p1', name: 'Launch pod', - members: [ - { _id: 'u1', username: 'sam' }, - { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: 'Scout', instanceId: 'uf2554d19c9' } }, - ], - }]; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantLive] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.resolve({ data: {} }); - }); - renderTools({ pods: perUserPods }); - const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); - expect(within(live).getByText('Scout may use it')).toBeInTheDocument(); - expect(screen.queryByText(/uf2554d19c9/)).not.toBeInTheDocument(); -}); - -test('TASK-624: a bot member with a blank displayName falls back to its non-default instanceId, then to the default username', async () => { - const fallbackPods = [{ - _id: 'p1', name: 'Launch pod', - members: [ - { _id: 'u1', username: 'sam' }, - { _id: 'a1', username: 'scout-uf2554d19c9', isBot: true, botMetadata: { displayName: ' ', instanceId: 'uf2554d19c9' } }, - { _id: 'a2', username: 'guide', isBot: true, botMetadata: { displayName: '', instanceId: 'default' } }, - ], - }]; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [{ ...grantLive, effectiveAudience: ['a1', 'a2'] }] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.resolve({ data: {} }); - }); - renderTools({ pods: fallbackPods }); - const live = await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); - expect(within(live).getByText('uf2554d19c9, guide may use it')).toBeInTheDocument(); -}); - -test('Revoke is two-click and posts the revoke verb, then reloads; a dead grant offers no Revoke', async () => { - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(axios.post).not.toHaveBeenCalled(); - fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' })); - expect(axios.post).not.toHaveBeenCalled(); - fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants/grant_live/revoke', undefined, expect.any(Object))); - await waitFor(() => expect(axios.get.mock.calls.filter(([url]) => url === '/api/grants')).toHaveLength(2)); - - fireEvent.click(screen.getByRole('button', { name: 'View GitHub in Ops' })); - const gone = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(gone).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument(); - expect(within(gone).getByText(/Revoked by sam 10m ago\./)).toBeInTheDocument(); - await waitFor(() => expect(within(gone).getByText('No calls yet.')).toBeInTheDocument()); -}); - -// Atlassian walk, launch day: the card kept saying "granted" after Revoke until a reload. -// The page now treats a confirmed revoke as final, so a stale or failed re-read -// cannot put the card back. -test('after Revoke the grant card says revoked in place, even when the re-read is stale or fails', async () => { - let reads = 0; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') { - reads += 1; - if (reads === 1) return Promise.resolve({ data: { grants: [grantLive] } }); - if (reads === 2) return Promise.resolve({ data: { grants: [grantLive] } }); // stale: server read lags the revoke - return Promise.reject(new Error('network')); // then a failed read - } - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - axios.post.mockResolvedValue({ data: { revoked: 1 } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(aside).getByText(/Granted by sam .*Ends /)).toBeInTheDocument(); - fireEvent.click(within(aside).getByRole('button', { name: 'Revoke' })); - fireEvent.click(within(aside).getByRole('button', { name: 'Yes, revoke it' })); - await waitFor(() => expect(reads).toBeGreaterThanOrEqual(2)); - // A tab refocus re-reads, and that read fails. - fireEvent(document, new Event('visibilitychange')); - await waitFor(() => expect(reads).toBeGreaterThanOrEqual(3)); - const card = await screen.findByRole('complementary', { name: 'Grant details' }); - await waitFor(() => expect(within(card).getByText(/Revoked by sam just now\./)).toBeInTheDocument()); - expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument(); - // The row went hollow with it, and the list did not blank on the failed read. - expect(screen.getByRole('button', { name: 'View GitHub in Launch pod' })).toBeInTheDocument(); - expect(screen.getByText(/revoked by sam/)).toBeInTheDocument(); -}); - -// Atlassian walk, launch day: only a connection that is no longer offered got -// "Remove connection", so nobody could disconnect Atlassian from the page. -describe('Disconnect on a live connection of an offered hosted connector', () => { - const atlassian = { ...linearEntry, installableId: 'atlassian', entryId: 'atlassian', label: 'Atlassian', - tools: [{ name: 'atlassian.search', requiredWriteMode: 'read', irreversible: false }], - connections: [{ connectionId: 'conn-atl', owner: 'me', repo: 'atlassian' }] }; - const grantAtl = { ...grantLinear, grantId: 'grant_atl', tools: ['atlassian.search'] }; - - test('an ungranted connection offers Disconnect, two clicks, and says so afterwards', async () => { - mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } }); - axios.delete.mockResolvedValue({ data: { message: 'Integration deleted successfully' } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Disconnect Atlassian' })); - expect(axios.delete).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole('button', { name: 'Yes, disconnect' })); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object))); - expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument(); - await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/installables')).toHaveLength(2)); - }); - - test('a stale catalogue read after Disconnect does not bring the connection back', async () => { - mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } }); // every read still lists the connection - axios.delete.mockResolvedValue({ data: { message: 'ok' } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Disconnect Atlassian' })); - fireEvent.click(screen.getByRole('button', { name: 'Yes, disconnect' })); - expect(await screen.findByText('Disconnected Atlassian.')).toBeInTheDocument(); - await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/installables')).toHaveLength(2)); - expect(screen.queryByRole('button', { name: /Disconnect Atlassian|Yes, disconnect/ })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); - }); - - test('a granted connection offers it in the grant card, and a provider page from the server is linked', async () => { - mockTwoEntries({ catalog: [atlassian], grants: { p1: [grantAtl], p2: [] } }); - axios.delete.mockResolvedValue({ data: { message: 'ok', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View Atlassian in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' })); - fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object))); - const link = await screen.findByRole('link', { name: 'Open provider settings' }); - expect(link).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps'); - }); - - test('a network failure says so and keeps the connection', async () => { - mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } }); - axios.delete.mockRejectedValue(new Error('network')); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Disconnect Atlassian' })); - fireEvent.click(screen.getByRole('button', { name: 'Yes, disconnect' })); - expect(await screen.findByText('Could not disconnect Atlassian.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Yes, disconnect' })).toBeInTheDocument(); - }); - - // A 502 is the server's partial removal: grants already revoked, row disconnected, - // only the provider's revoke failed, and the body names the page to visit. - test('a 502 partial removal shows the grant revoked and links the provider page', async () => { - mockTwoEntries({ catalog: [atlassian], grants: { p1: [grantAtl], p2: [] } }); - axios.delete.mockRejectedValue({ response: { status: 502, data: { code: 'provider_revoke_failed', revokeAt: 'https://id.atlassian.com/manage-profile/apps' } } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View Atlassian in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' })); - fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); - expect(await screen.findByRole('link', { name: 'Open provider settings' })).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps'); - const card = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(card).getByText(/Revoked by sam/)).toBeInTheDocument(); - expect(within(card).queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument(); - }); - - // A 502 from the ingress while a pod restarts never reached the route: nothing was revoked. - test('a bare 502 (no route code) leaves the grant live and says the disconnect failed', async () => { - mockTwoEntries({ catalog: [atlassian], grants: { p1: [grantAtl], p2: [] } }); - axios.delete.mockRejectedValue({ response: { status: 502, data: 'Bad gateway' } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View Atlassian in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - fireEvent.click(within(aside).getByRole('button', { name: 'Disconnect Atlassian' })); - fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); - expect(await within(aside).findByText('Could not disconnect Atlassian.')).toBeInTheDocument(); - expect(within(aside).getByRole('button', { name: 'Revoke' })).toBeInTheDocument(); - expect(within(aside).queryByText(/Revoked by/)).not.toBeInTheDocument(); - }); - - test('two connections of one entry offer no Disconnect, since a grant does not name its connection', async () => { - const two = { ...atlassian, connections: [...atlassian.connections, { connectionId: 'conn-atl-2', owner: 'me', repo: 'atlassian' }] }; - mockTwoEntries({ catalog: [two], grants: { p1: [], p2: [] } }); - renderTools(); - await screen.findByText('Atlassian'); - expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument(); - }); - - test('GitHub (the instance App connection) never offers Disconnect', async () => { - renderTools(); - await screen.findByRole('button', { name: 'View GitHub in Launch pod' }); - expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument(); - }); -}); - -test('the catalogue draws the not-yet row, and Add mints the grant exactly as the server takes it — never a brokerId', async () => { - mockApi([githubEntry]); - // No live grant anywhere → a not-yet row; the two dead/live fixtures are replaced by a revoked one only. - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantRevoked] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }, { name: 'hosted-smoke', userId: 'x9', internal: true }] } }); - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } }); - renderTools(); - expect(await screen.findByText('0 granted · 1 more')).toBeInTheDocument(); - expect(screen.getByText('Issues and pull requests.')).toBeInTheDocument(); - expect(screen.getByText('read, or read and write')).toBeInTheDocument(); - expect(screen.getByText('not granted')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Add' })); - const form = await screen.findByRole('complementary', { name: 'Add GitHub' }); - // Default: read, every non-internal seat in the room, 7 days; under read only read tools are on the list. - expect(within(form).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true'); - expect(within(form).getByText('github.list_issues')).toBeInTheDocument(); - expect(within(form).queryByText('github.comment_on_issue')).toBeNull(); - expect(within(form).getByText('nothing asks first')).toBeInTheDocument(); - expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked(); - fireEvent.click(within(form).getByRole('button', { name: 'read and write' })); - expect(within(form).getByText('github.comment_on_issue')).toBeInTheDocument(); - expect(within(form).getByText('github.comment_on_issue, github.close_issue ask first')).toBeInTheDocument(); - fireEvent.change(within(form).getByRole('combobox', { name: 'ends' }), { target: { value: '30' } }); - fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)); - const [url, body] = axios.post.mock.calls[0]; - expect(url).toBe('/api/grants'); - expect(body).toMatchObject({ connectionId: 'conn-1', target: { kind: 'pod', id: 'p1' }, writeMode: 'write', audience: ['a1'], tools: ['github.list_issues', 'github.comment_on_issue', 'github.close_issue'] }); - // Both are the server's: the broker from the catalogue, the installation from the Connection (#1677). - expect(body).not.toHaveProperty('brokerId'); - expect(body).not.toHaveProperty('installationId'); - expect(Object.keys(body).sort()).toEqual(['audience', 'connectionId', 'expiresAt', 'target', 'tools', 'writeMode']); - const days = (new Date(body.expiresAt).getTime() - Date.now()) / 86400000; - expect(days).toBeGreaterThan(29.9); - expect(days).toBeLessThan(30.1); - await waitFor(() => expect(axios.get.mock.calls.filter(([u]) => u === '/api/grants')).toHaveLength(2)); -}); - -test('a not-yet row without a Connection or on an unconfigured instance offers no Add', async () => { - mockApi([{ ...githubEntry, connections: [] }]); - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [{ ...githubEntry, connections: [] }] } }); - if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } }); - return Promise.resolve({ data: { agents: [] } }); - }); - renderTools(); - expect(await screen.findByText('install the GitHub App first · an admin does this once')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Install GitHub App' })).toBeNull(); -}); - -test('an admin can install the GitHub App from the no-connection Tools row', async () => { - const noConnection = { ...githubEntry, connections: [] }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [noConnection] } }); - if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } }); - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'u1' } } }); - const admin = { - ...authValue, - currentUser: { ...authValue.currentUser, role: 'admin' }, - user: { ...authValue.user, role: 'admin' }, - }; - render(); - - fireEvent.click(await screen.findByRole('button', { name: 'Install GitHub App' })); - const form = await screen.findByRole('complementary', { name: 'Install GitHub App' }); - fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } }); - fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } }); - fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } }); - fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/integrations/github-app', - { installationId: 'install-42', owner: 'Team-Commonly', repo: 'commonly' }, - expect.any(Object), - )); - await waitFor(() => expect(screen.queryByRole('complementary', { name: 'Install GitHub App' })).toBeNull()); -}); - -test('an admin is told when another administrator already owns the GitHub App installation', async () => { - const noConnection = { ...githubEntry, connections: [] }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [noConnection] } }); - if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } }); - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - axios.post.mockResolvedValue({ data: { integration: { installationId: 'install-42', createdBy: 'other-admin' } } }); - const admin = { - ...authValue, - currentUser: { ...authValue.currentUser, _id: 'u2', role: 'admin' }, - user: { ...authValue.user, _id: 'u2', role: 'admin' }, - }; - render(); - - fireEvent.click(await screen.findByRole('button', { name: 'Install GitHub App' })); - const form = await screen.findByRole('complementary', { name: 'Install GitHub App' }); - fireEvent.change(within(form).getByRole('textbox', { name: 'installation ID' }), { target: { value: 'install-42' } }); - fireEvent.change(within(form).getByRole('textbox', { name: 'owner' }), { target: { value: 'Team-Commonly' } }); - fireEvent.change(within(form).getByRole('textbox', { name: 'repository' }), { target: { value: 'commonly' } }); - fireEvent.click(within(form).getByRole('button', { name: 'Install GitHub App' })); - - await waitFor(() => expect(within(form).getByRole('alert')).toHaveTextContent('This GitHub App is already installed by another administrator.')); - expect(screen.getByRole('complementary', { name: 'Install GitHub App' })).toBeInTheDocument(); -}); - -test('an unavailable not-yet tool row offers the same Ask affordance as unavailable channels', async () => { - const unavailable = { ...githubEntry, available: false, connections: [] }; - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [unavailable] } }); - if (url.endsWith('/grants')) return Promise.resolve({ data: { grants: [] } }); - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - renderTools(); - const ask = await screen.findByRole('link', { name: 'Ask' }); - expect(screen.queryByText('not enabled on this instance · ask your operator')).not.toBeInTheDocument(); - expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request'); - expect(ask.closest('.v2-connector-row')).toHaveClass('v2-connector-row--not-enabled'); -}); - -test('Change access mints a new grant then revokes the old one; Grant again on a dead grant only mints', async () => { - mockApi([githubEntry]); - axios.post.mockResolvedValue({ data: { grantId: 'grant_new' } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - // Under `write` the aside names the irreversible tools from the catalogue. - expect(within(aside).getByText('asks a person first').nextElementSibling).toHaveTextContent('every write asks first'); - fireEvent.click(within(aside).getByRole('button', { name: 'Change access' })); - const form = await screen.findByRole('complementary', { name: 'Change access' }); - expect(within(form).getByRole('button', { name: 'read and write, ask first' })).toHaveAttribute('aria-pressed', 'true'); - expect(within(form).getByRole('checkbox', { name: 'Scout' })).toBeChecked(); - fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); - expect(axios.post.mock.calls[0][0]).toBe('/api/grants'); - expect(axios.post.mock.calls[1][0]).toBe('/api/grants/grant_live/revoke'); - - axios.post.mockClear(); - // The first grant is revoked too now (the page keeps a confirmed revoke), so pick the Ops row's own button. - fireEvent.click(within(screen.getByRole('button', { name: 'View GitHub in Ops' }).closest('article')).getByRole('button', { name: 'Grant again' })); - const again = await screen.findByRole('complementary', { name: 'Add GitHub' }); - expect(within(again).getByRole('heading', { name: 'GitHub · Ops' })).toBeInTheDocument(); - fireEvent.click(within(again).getByRole('button', { name: 'Grant' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(1)); - expect(axios.post.mock.calls[0][0]).toBe('/api/grants'); -}); - -test('Revoke renders only for the granter', async () => { - mockApi([githubEntry]); - const member = { ...authValue, currentUser: { _id: 'u2', username: 'rae' }, user: { _id: 'u2', username: 'rae' } }; - render(); - fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(aside).getByText(/Granted by sam/)).toBeInTheDocument(); - expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull(); - expect(within(aside).queryByRole('button', { name: 'Change access' })).toBeNull(); - // The trail is the room's: a member still reads it. - await waitFor(() => expect(within(aside).getByText('calls').previousElementSibling).toHaveTextContent('3')); -}); - -test('Grant again renders only for the granter', async () => { - mockApi([githubEntry]); - const member = { ...authValue, currentUser: { _id: 'u2', username: 'rae' }, user: { _id: 'u2', username: 'rae' } }; - render(); - await screen.findByRole('button', { name: 'View GitHub in Ops' }); - // The dead row offers a member Manage (read the grant), never the mint. - expect(screen.queryByRole('button', { name: 'Grant again' })).toBeNull(); - expect(screen.getAllByRole('button', { name: 'Manage' })).toHaveLength(2); -}); - -test('Change access keeps the minted grant when the revoke fails and retries only the revoke', async () => { - mockApi([githubEntry]); - axios.post.mockImplementation((url) => (url === '/api/grants' - ? Promise.resolve({ data: { grantId: 'grant_new' } }) - : Promise.reject(new Error('revoke down')))); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); - fireEvent.click(within(await screen.findByRole('complementary', { name: 'Grant details' })).getByRole('button', { name: 'Change access' })); - fireEvent.click(within(await screen.findByRole('complementary', { name: 'Change access' })).getByRole('button', { name: 'Grant' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(2)); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(await within(aside).findByText('The new grant is live. This old one still is too — its revoke did not go through.')).toBeInTheDocument(); - expect(within(aside).queryByRole('button', { name: 'Revoke' })).toBeNull(); - axios.post.mockResolvedValue({ data: { grantId: 'grant_live', revoked: 1 } }); - fireEvent.click(within(aside).getByRole('button', { name: 'Revoke the old grant' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledTimes(3)); - // Only the revoke went out again — no third mint. - expect(axios.post.mock.calls.map(([u]) => u)).toEqual(['/api/grants', '/api/grants/grant_live/revoke', '/api/grants/grant_live/revoke']); -}); - -test('TASK-133: a live grant on one entry leaves the other entry under Not yet', async () => { - mockTwoEntries(); - const { container } = renderTools(); - expect(await screen.findByRole('heading', { name: 'Tools' })).toBeInTheDocument(); - // One entry granted, so exactly one is still to grant — and the entry the - // grant names is the one that leaves the list (label and glyph from its own entry). - expect(screen.getByText('1 granted · 1 more')).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'View Linear in Launch pod' })).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'View GitHub in Launch pod' })).not.toBeInTheDocument(); - const notYet = container.querySelectorAll('.v2-connector-row--not-yet'); - expect(notYet).toHaveLength(1); - expect(notYet[0].textContent).toContain('GitHub'); - expect(notYet[0].textContent).toContain('Issues and pull requests.'); -}); - -test('TASK-133: the granted entry itself leaves Not yet, the other one stays', async () => { - mockTwoEntries({ grants: { p1: [grantLive], p2: [grantRevoked] } }); - const { container } = renderTools(); - expect(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })).toBeInTheDocument(); - const notYet = container.querySelectorAll('.v2-connector-row--not-yet'); - expect(notYet).toHaveLength(1); - expect(notYet[0].textContent).toContain('Linear'); - expect(notYet[0].textContent).not.toContain('GitHub'); -}); - -test('TASK-133: with every entry granted the header carries no "more"', async () => { - mockTwoEntries({ grants: { p1: [grantLive, grantLinear], p2: [grantRevoked] } }); - const { container } = renderTools(); - expect(await screen.findByText('2 granted')).toBeInTheDocument(); - expect(screen.queryByText(/· \d+ more/)).not.toBeInTheDocument(); - expect(container.querySelectorAll('.v2-connector-row--not-yet')).toHaveLength(0); -}); - -test('a hosted grant whose entry is missing stays unknown and never falls back to GitHub', async () => { - const orphanedLinearGrant = { ...grantLinear, grantId: 'grant_orphan', writeMode: 'write', tools: ['linear.list_issues'] }; - mockTwoEntries({ catalog: [githubEntry], grants: { p1: [orphanedLinearGrant], p2: [] } }); - renderTools(); - - const row = await screen.findByRole('button', { name: 'View Unknown connector in Launch pod' }); - expect(row).toBeInTheDocument(); - expect(row).not.toHaveTextContent('GitHub'); - expect(row).toHaveTextContent('write policy unavailable'); - expect(row.querySelector('.v2-connector-row__glyph svg')).toBeNull(); -}); - -test('Linear exposes only its pinned read mode in the not-yet row and Add form', async () => { - mockTwoEntries({ grants: { p1: [grantLive], p2: [] } }); - const { container } = renderTools(); - await screen.findByText('1 granted · 1 more'); - - const linearRow = Array.from(container.querySelectorAll('.v2-connector-row--not-yet')) - .find((candidate) => candidate.textContent.includes('Linear')); - expect(linearRow).toBeTruthy(); - expect(linearRow).toHaveTextContent('read'); - expect(linearRow).not.toHaveTextContent('read, or read and write'); - - fireEvent.click(within(linearRow).getByRole('button', { name: 'Add' })); - const form = await screen.findByRole('complementary', { name: 'Add Linear' }); - const modeGroup = within(form).getByRole('group', { name: 'what it may do' }); - expect(within(modeGroup).getAllByRole('button')).toHaveLength(1); - expect(within(modeGroup).getByRole('button', { name: 'read' })).toHaveAttribute('aria-pressed', 'true'); -}); - -test('search filters by tool name; with no grants and no catalogue it renders nothing', async () => { - renderTools(); - await screen.findByRole('heading', { name: 'Tools' }); - fireEvent.change(screen.getByRole('searchbox', { name: 'Search tools' }), { target: { value: 'comment' } }); - expect(screen.getAllByRole('button', { name: /^View GitHub/ })).toHaveLength(2); - fireEvent.change(screen.getByRole('searchbox', { name: 'Search tools' }), { target: { value: 'zzz' } }); - expect(screen.getByText('Nothing matches.')).toBeInTheDocument(); - - axios.get.mockImplementation((url) => Promise.resolve({ data: url.includes('/grants') ? { grants: [] } : { agents: [] } })); - const { container } = render( - , - ); - await waitFor(() => expect(container.textContent).not.toContain('Loading tools')); - expect(container.querySelector('.v2-tools')).toBeNull(); -}); - -/** - * Scope §4, step 7's named test: "the page's call to start is credentialed". - * - * The start route binds a connect to the browser that began it with an - * httpOnly cookie on its OWN response, and the API is a different origin from - * the app. Without `withCredentials` the browser drops the `Set-Cookie`, so a - * person consents at the vendor and comes back to a row that is still not - * connected, every callback refusing `invalid_state`. A backend suite sets the - * cookie itself and cannot witness this flag; this arm is the one that can. - */ -test('step 7: the page\'s call to start a hosted connect is credentialed', async () => { - const hosted = { - installableId: 'linear', list: 'tools', label: 'Linear', description: 'Issues and projects in Linear.', - connectionType: 'hosted-mcp', entryId: 'linear', available: true, - broker: { id: 'commonly-grant-broker' }, - tools: [{ name: 'linear.list_issues', requiredWriteMode: 'read', irreversible: false }], - connections: [], - }; - mockApi([hosted]); - axios.post.mockResolvedValue({ data: { authorizeUrl: 'https://mcp.linear.app/authorize?state=s' } }); - const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() }; - const open = jest.spyOn(window, 'open').mockReturnValue(opened as never); - try { - renderTools(); - const connect = await screen.findByRole('button', { name: 'Connect' }); - fireEvent.click(connect); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/integrations/connect/hosted-mcp/linear/start', - {}, - expect.objectContaining({ withCredentials: true }), - )); - expect(opened.location.assign).toHaveBeenCalledWith('https://mcp.linear.app/authorize?state=s'); - } finally { - open.mockRestore(); - } -}); - -test('a hosted connector shows vendor setup guidance before connect', async () => { - mockApi([{ ...linearEntry, connections: [], connectNotice: 'Ask your admin to allow this domain.' }]); - renderTools(); - expect(await screen.findByText('Ask your admin to allow this domain.')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); -}); - -// TASK-290: GitHub's App user token reaches only the repositories the App is -// installed on, so the page that picks them sits beside Connect and stays -// after connecting, while the person decides what to grant. -test('a hosted connector links to where the person chooses its reach, before and after connecting', async () => { - const accessPage = 'https://github.com/apps/commonly-connect/installations/new'; - mockApi([{ ...linearEntry, connections: [], accessPage }]); - const first = renderTools(); - const before = await screen.findByRole('link', { name: 'Choose what Linear can reach' }); - expect(before).toHaveAttribute('href', accessPage); - expect(before).toHaveAttribute('target', '_blank'); - expect(before).toHaveAttribute('rel', 'noopener noreferrer'); - expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); - first.unmount(); - - mockApi([{ ...linearEntry, accessPage }]); - renderTools(); - expect(await screen.findByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage); - expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); -}); - -// A live grant takes the entry off the offer list, so the page moves to the -// grant's details. Only the granter, who owns the connection, is sent there. -test('a live hosted grant keeps the reach link on its details for the granter only', async () => { - const accessPage = 'https://github.com/apps/commonly-connect/installations/new'; - const entry = { ...linearEntry, accessPage }; - mockTwoEntries({ catalog: [entry], grants: { p1: [grantLinear], p2: [] } }); - const first = renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View Linear in Launch pod' })); - const aside = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(aside).getByRole('link', { name: 'Choose what Linear can reach' })).toHaveAttribute('href', accessPage); - first.unmount(); - - mockTwoEntries({ catalog: [entry], grants: { p1: [{ ...grantLinear, grantedBy: 'u2' }], p2: [] } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View Linear in Launch pod' })); - const other = await screen.findByRole('complementary', { name: 'Grant details' }); - expect(within(other).queryByRole('link', { name: /can reach/ })).toBeNull(); -}); - -test('a hosted connector without an access page offers no reach link', async () => { - mockApi([{ ...linearEntry, connections: [] }]); - renderTools(); - expect(await screen.findByRole('button', { name: 'Connect' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: /can reach/ })).toBeNull(); -}); - -test('a hidden hosted entry has no offering while its existing connection can be removed', async () => { - mockApi([]); - const prior = axios.get.getMockImplementation(); - axios.get.mockImplementation((url, config) => { - if (url === '/api/installables') return Promise.resolve({ data: { - installables: [], - hiddenToolConnections: [{ connectionId: 'conn-hidden', entryId: 'airtable', label: 'Airtable', status: 'connected', revokePage: 'https://airtable.com/account' }], - } }); - return prior(url, config); - }); - axios.delete.mockResolvedValue({ data: { message: 'removed' } }); - renderTools(); - expect(await screen.findByText('Airtable')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Remove connection' })); - fireEvent.click(screen.getByRole('button', { name: 'Yes, remove' })); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-hidden', expect.anything())); - expect(await screen.findByText(/Connection removed. Review its access at Airtable/)).toBeInTheDocument(); -}); - -test('a hosted connect start error uses fixed copy instead of response text', async () => { - mockApi([{ ...linearEntry, connections: [] }]); - axios.post.mockRejectedValue({ - response: { data: { error: 'client_not_configured', message: 'spoofed vendor text' } }, - }); - const opened = { opener: null, location: { assign: jest.fn() }, close: jest.fn() }; - const open = jest.spyOn(window, 'open').mockReturnValue(opened as never); - try { - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Connect' })); - expect(await screen.findByRole('alert')).toHaveTextContent( - 'This connector’s sign-in setup needs attention. Ask an administrator to check it.', - ); - expect(screen.queryByText(/spoofed vendor text/)).toBeNull(); - expect(opened.close).toHaveBeenCalled(); - } finally { - open.mockRestore(); - } -}); - -test.each([400, 403])('a hosted grant HTTP %s stays open with the server refusal', async (status) => { - mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } }); - axios.post.mockRejectedValue({ response: { status, data: { - error: 'invalid_target', message: 'The selected target is unavailable.', - } } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Add', exact: true })); - const form = await screen.findByRole('complementary', { name: 'Add Linear' }); - fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); - expect(await within(form).findByRole('alert')).toHaveTextContent('The selected target is unavailable.'); - expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument(); - expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ - connectionId: 'conn-2', target: { kind: 'pod', id: 'p1' }, audience: ['a1'], - }), expect.any(Object)); -}); - -test.each([undefined, {}, { error: 'invalid_target' }, 'fallback', { grantId: '' }, { grantId: ' ' }])( - 'an unconfirmed mint response keeps the form and old grant: %p', async (body) => { - mockApi([githubEntry]); - axios.post.mockResolvedValue({ data: body }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'View GitHub in Launch pod' })); - fireEvent.click(within(await screen.findByRole('complementary', { name: 'Grant details' })) - .getByRole('button', { name: 'Change access' })); - const form = await screen.findByRole('complementary', { name: 'Change access' }); - fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); - expect(await within(form).findByRole('alert')).toHaveTextContent('Could not grant it.'); - expect(screen.getByRole('complementary', { name: 'Change access' })).toBeInTheDocument(); - expect(axios.post.mock.calls.map(([url]) => url)).toEqual(['/api/grants']); - }, -); - - -test.each(['unavailable', 'removed'])('a catalogue refresh (%s) cannot hide a refused hosted grant draft', async (refresh) => { - mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } }); - const message = 'Agents can self-install into this pod. Make it invite-only to use this connector.'; - axios.post.mockRejectedValue({ response: { status: 403, data: { error: 'outsider_grant_pod_joinable', message } } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Add' })); - const form = await screen.findByRole('complementary', { name: 'Add Linear' }); - fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); - expect(await within(form).findByRole('alert')).toHaveTextContent(message); - const previousGet = axios.get.getMockImplementation(); - axios.get.mockImplementation((url, ...args) => url === '/api/installables' - ? (refresh === 'unavailable' ? Promise.reject(new Error('offline')) : Promise.resolve({ data: { installables: [] } })) - : previousGet(url, ...args)); - const reads = axios.get.mock.calls.length; - await act(async () => { document.dispatchEvent(new Event('visibilitychange')); }); - await waitFor(() => expect(axios.get.mock.calls.length).toBeGreaterThan(reads)); - expect(screen.getByRole('complementary', { name: 'Add Linear' })).toBeInTheDocument(); - expect(screen.getByRole('alert')).toHaveTextContent(message); - expect(screen.getByText('0 granted')).toBeInTheDocument(); -}); - -test('hosted grant picker sends the selected pod ID and keeps the agent User ID in audience', async () => { - mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } }); - const previousGet = axios.get.getMockImplementation(); - axios.get.mockImplementation((url, ...args) => url === '/api/registry/pods/p2/agents' - ? Promise.resolve({ data: { agents: [{ name: 'reed', userId: 'a2' }] } }) - : previousGet(url, ...args)); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Add' })); - const form = await screen.findByRole('complementary', { name: 'Add Linear' }); - fireEvent.change(within(form).getByRole('combobox', { name: 'pod' }), { target: { value: 'p2' } }); - fireEvent.click(within(form).getByRole('button', { name: 'Grant' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ - target: { kind: 'pod', id: 'p2' }, audience: ['a2'], - }), expect.anything())); -}); - - -test('the dialog cannot submit an empty audience and re-enables grant when an agent is selected', async () => { - mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [] } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Add' })); - const form = await screen.findByRole('complementary', { name: 'Add Linear' }); - const submit = within(form).getByRole('button', { name: 'Grant' }); - expect(submit).toBeEnabled(); - fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' })); - expect(within(form).getByRole('status')).toHaveTextContent('Select at least one agent to grant access.'); - expect(submit).toBeDisabled(); - fireEvent.click(submit); - expect(axios.post).not.toHaveBeenCalled(); - fireEvent.click(within(form).getByRole('checkbox', { name: 'Scout' })); - expect(submit).toBeEnabled(); - fireEvent.click(submit); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'] }), expect.anything())); -}); - - -test('Grant again on an existing empty-audience row requires an explicit new selection', async () => { - mockTwoEntries({ catalog: [linearEntry], grants: { p1: [], p2: [{ ...grantRevoked, tools: ['linear.list_issues'], effectiveAudience: [] }] } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Grant again' })); - const form = await screen.findByRole('complementary', { name: 'Add Linear' }); - const agent = within(form).getByRole('checkbox', { name: 'Scout' }); - expect(agent).not.toBeChecked(); - const submit = within(form).getByRole('button', { name: 'Grant' }); - expect(submit).toBeDisabled(); - fireEvent.click(submit); - expect(axios.post).not.toHaveBeenCalled(); - fireEvent.click(agent); - fireEvent.click(submit); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ audience: ['a1'], target: { kind: 'pod', id: 'p2' } }), expect.anything())); -}); - - -test.each([ - { label: 'human-only', audience: ['u1'], selected: false }, - { label: 'mixed human and agent', audience: ['u1', 'a1'], selected: true }, -])('Grant again restores only current agents from a $label legacy audience', async ({ audience, selected }) => { - mockTwoEntries({ catalog: [githubEntry], grants: { p1: [], p2: [{ ...grantRevoked, effectiveAudience: audience }] } }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: 'Grant again' })); - const form = await screen.findByRole('complementary', { name: 'Add GitHub' }); - const scout = within(form).getByRole('checkbox', { name: 'Scout' }); - const submit = within(form).getByRole('button', { name: 'Grant' }); - expect(scout.checked).toBe(selected); - if (!selected) { - expect(submit).toBeDisabled(); - expect(within(form).getByText('Select at least one agent to grant access.')).toBeInTheDocument(); - fireEvent.click(submit); - expect(axios.post).not.toHaveBeenCalled(); - fireEvent.click(scout); - } - expect(submit).toBeEnabled(); - fireEvent.click(submit); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/grants', expect.objectContaining({ - target: { kind: 'pod', id: 'p2' }, audience: ['a1'], - }), expect.anything())); -}); - -test('a Tools row shows the zh-CN catalog copy, and an unmapped entry keeps the server text', async () => { - const linear = { - installableId: 'linear', list: 'tools', label: 'Linear', description: 'Read Linear issues and projects.', - connectionType: 'hosted-mcp', entryId: 'linear', available: true, tools: [], connections: [], installation: null, integration: null, - }; - mockApi([linear, { ...linear, installableId: 'acme-tools', label: 'Acme', description: 'Acme server copy.', entryId: 'acme' }]); - await i18nReady; - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - try { - renderTools(); - expect(await screen.findByText('你自己的 Linear 工作空间中的议题、项目、周期和发布。')).toBeInTheDocument(); - expect(screen.getByText('Acme server copy.')).toBeInTheDocument(); - } finally { - await act(async () => { await i18n.changeLanguage('en'); }); - } -}); - -test('the open grant form reads in zh-CN, not English', async () => { - await i18nReady; - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - try { - axios.get.mockImplementation((url) => { - if (url === '/api/installables') return Promise.resolve({ data: { installables: [githubEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [grantRevoked] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [{ name: 'scout', displayName: 'Scout', userId: 'a1' }] } }); - if (url.includes('/registry/pods/')) return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) return Promise.resolve({ data: { grantId: 'g', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - return Promise.reject(new Error(`unmocked ${url}`)); - }); - renderTools(); - fireEvent.click(await screen.findByRole('button', { name: /^添加$|^Add$/ })); - const form = await screen.findByRole('complementary'); - expect(within(form).getByText('无需确认')).toBeInTheDocument(); - expect(within(form).queryByText('nothing asks first')).toBeNull(); - expect(within(form).queryByText('read and write, ask first')).toBeNull(); - // No English button left in the zh-CN form. - expect(within(form).queryByRole('button', { name: 'Grant' })).toBeNull(); - } finally { - await act(async () => { await i18n.changeLanguage('en'); }); - } -}); diff --git a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx b/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx deleted file mode 100644 index 9210f8ec0e..0000000000 --- a/frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx +++ /dev/null @@ -1,1734 +0,0 @@ -// @ts-nocheck -// Signal Connectors page: rows preserve the Phase 1 verbs while the selected -// channel owns code, confirmation, relay controls, and disconnect in its aside. -import React from 'react'; -import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; -import V2ConnectorsPage, { INSTALL_LOCK_TTL_MS, installableLifecyclePath } from '../components/V2ConnectorsPage'; -import { PlatformGlyph } from '../icons/platforms'; -import { AuthContext } from '../../context/AuthContext'; -import en from '../../i18n/locales/en.json'; -import i18n, { i18nReady } from '../../i18n'; - -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', - installationId: 'install-telegram-u1', - type: 'telegram', - status: 'pending', - createdAt: new Date(Date.now() - 60_000).toISOString(), - config: { connectCode: 'abc123', connectCodeExpiresAt: new Date(Date.now() + 5 * 60_000).toISOString() }, - podId: { _id: 'p1', name: 'Rewire Live Demo' }, - }, - { - _id: 'i-live', - installationId: 'install-slack-u1', - type: 'slack', - status: 'connected', - createdAt: new Date(Date.now() - 3_600_000).toISOString(), - updatedAt: new Date().toISOString(), - config: { chatTitle: 'Rewire crew', liveRelay: false }, - podId: { _id: 'p2', name: 'Ops' }, - }, -]; - -const mockGets = (list = connectors, installables = null) => { - axios.get.mockImplementation((url) => { - if (url === '/api/integrations/user/all') return Promise.resolve({ data: list }); - if (url === '/api/installables' && installables) return Promise.resolve({ data: { installables } }); - 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 hostedLinearCatalogEntry = (connectionId = 'i-linear') => ({ - installableId: 'linear', - list: 'tools', - label: 'Linear', - description: 'Read Linear issues and projects.', - connectionType: 'hosted-mcp', - entryId: 'linear', - available: true, - tools: [], - connections: [{ connectionId, owner: 'sam', repo: '' }], - installation: null, - integration: null, -}); - -const renderPage = () => render( - - - - - , -); - -// The reconciler's own constant — the reason a person reads when the channel -// record behind a connector is gone. It replaced the old 'projection missing' -// wording in the TASK-131 copy pass. -const REASON_CHANNEL_GONE = "This connector's channel is gone. Retry to rebuild it."; - -describe('V2ConnectorsPage', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.spyOn(window, 'open').mockReturnValue(null); - window.history.replaceState({}, '', '/v2/connectors'); - }); - - afterEach(() => jest.restoreAllMocks()); - - it('Direction A: a linked row carries a mono kicker, a relay mark with its sentence in the label, and Manage as the gear', async () => { - mockGets([connectors[1]]); - renderPage(); - const manage = await screen.findByRole('button', { name: 'Manage' }); - expect(manage).toHaveClass('v2-connector-row__action--icon'); - expect(manage).toHaveAttribute('title', 'Manage'); - expect(manage.textContent).toBe(''); - expect(manage.querySelector('svg')).not.toBeNull(); - const row = manage.closest('.v2-connector-row'); - expect(row?.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/ · added /); - const mark = row?.querySelector('.v2-connector-row__mark'); - expect(mark).not.toBeNull(); - expect(mark).toHaveAttribute('role', 'img'); - // TASK-162 (3): the glyph carries the MODE WORD (what the mark means); the - // consequence is the visible words beside it, so line 3 is never a bare mark. - expect(mark?.getAttribute('aria-label')).toMatch(/^(attention|mirror|relay off)$/); - expect(mark?.getAttribute('title')).toBe(mark?.getAttribute('aria-label')); - expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).toBe('messages stay in the pod'); - // The mode word is NOT repeated on line 3: at ≤760 the kicker carries it, so - // 'relay off · messages stay in the pod' said it twice (TASK-162 UX gate). - expect(row?.querySelector('.v2-connector-row__mark-text')?.textContent).not.toMatch(/^(attention|mirror|relay off) ·/); - expect(row?.querySelector('.v2-connector-row__kicker-mode')?.textContent).toMatch(/attention|mirror|relay off/); - expect(row?.querySelector('.v2-connector-row__when')).toBeNull(); - }); - - // TASK-177. The mark and the Tools glyph agree at 14px through TWO independent - // literals in two files: `size={14}` at the mark's call site, and - // V2ConnectorTools' own local `G` (`:157`, `width="14" height="14"`) — it does - // not import `MarkGlyph`, and `icons/glyphs.tsx`'s `G` defaults to 16. - // So nothing shared carries the agreement and nothing tested it: drop the prop - // and the mark renders 16 while Tools stays 14, silently desyncing the two rows - // v2.css claims cannot drift. And a `14` asserted in one file would have passed - // through every misreading of this chain, so what is pinned is the AGREEMENT: - // whichever number the design lands on, both glyphs must carry it. The absolute - // size stays the gate's decision, not this test's. - it('TASK-177: the connector mark and the Tools mode glyph render at the same size', async () => { - const toolsEntry = { - installableId: 'github', list: 'tools', label: 'GitHub', description: 'Issues and pull requests.', available: true, - broker: { id: 'commonly-grant-broker' }, - tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }], - connections: [], - }; - const grant = { - grantId: 'grant_live', installationId: 'inst-1', target: { kind: 'pod', id: 'p1' }, tools: ['github.list_issues'], - writeMode: 'read', budget: { calls: 50, windowMs: 3600000 }, effectiveAudience: [], - expiresAt: new Date(Date.now() + 6 * 86400000).toISOString(), revokedAt: null, revokedBy: null, - parentGrantId: null, rootGrantId: null, createdAt: new Date().toISOString(), grantedBy: 'u1', - }; - axios.get.mockImplementation((url) => { - if (url === '/api/integrations/user/all') return Promise.resolve({ data: [connectors[1]] }); - if (url === '/api/pods') return Promise.resolve({ data: [{ _id: 'p1', name: 'Launch pod', type: 'chat' }] }); - if (url === '/api/installables') return Promise.resolve({ data: { installables: [toolsEntry] } }); - if (url === '/api/grants') return Promise.resolve({ data: { grants: [grant] } }); - if (url === '/api/registry/pods/p1/agents') return Promise.resolve({ data: { agents: [] } }); - if (url.includes('/calls')) { - return Promise.resolve({ data: { grantId: 'grant_live', calls: [], counts: { total: 0, ok: 0, refused: 0, pending_approval: 0, failed: 0 } } }); - } - return Promise.resolve({ data: [] }); - }); - const { container } = renderPage(); - // Both glyphs have to actually BE there, or the comparison would pass on two - // absent nodes — the vacuity the same-size claim is easiest to fake. - const markSvg = await waitFor(() => { - expect(container.querySelector('.v2-connector-row__mark svg')).not.toBeNull(); - return container.querySelector('.v2-connector-row__mark svg') as SVGElement; - }); - const modeSvg = await waitFor(() => { - expect(container.querySelector('.v2-tools__mode svg')).not.toBeNull(); - return container.querySelector('.v2-tools__mode svg') as SVGElement; - }); - // Positive control: these attributes are the instrument, so prove they carry a - // value at all (an empty string would make the equality below meaningless). - // Both attributes, both glyphs: controlling only `width` left the height - // comparison able to pass on null === null — strip `height` from *both* `G` - // components and a width-only control stays green (sprint-review's gate). - expect(markSvg.getAttribute('width')).toMatch(/^\d+$/); - expect(markSvg.getAttribute('height')).toMatch(/^\d+$/); - expect(modeSvg.getAttribute('width')).toMatch(/^\d+$/); - expect(modeSvg.getAttribute('height')).toMatch(/^\d+$/); - expect(markSvg.getAttribute('width')).toBe(modeSvg.getAttribute('width')); - expect(markSvg.getAttribute('height')).toBe(modeSvg.getAttribute('height')); - }); - - it('TASK-162: line 3 never repeats the mode word the kicker already carries', async () => { - // The prefixes lived in the component's defaultValue, which is exactly where - // they could not be guarded: once the key exists in the catalog the catalog - // wins, so reverting the defaultValue changes nothing rendered. The copy now - // lives in en.json, so that is where the rule is asserted. - const kickerByRow: Array<[keyof typeof en.connectors, string]> = [ - ['rowAttention', 'attention'], - ['rowMirror', 'mirror'], - ['rowRelayOff', 'relay off'], - ]; - for (const [key, modeWord] of kickerByRow) { - expect(en.connectors[key]).toBeTruthy(); - expect(en.connectors[key].startsWith(modeWord)).toBe(false); - } - }); - - // TASK-338: dead rows sit under the shared "inactive · N" fold, collapsed. - const openInactiveFold = async () => { - fireEvent.click(await screen.findByRole('button', { name: /^inactive · \d+$/ })); - }; - - it('TASK-338: the unbuilt-provider placeholder is gone, and no not-yet copy renders', async () => { - mockGets([]); - const { container } = renderPage(); - await waitFor(() => expect(container.querySelector('.v2-connectors__rows')).not.toBeNull()); - expect(container.querySelector('.v2-connector-row__names')).toBeNull(); - expect(screen.queryByText(/not yet/i)).toBeNull(); - expect(screen.queryByText(/Tell us which channel/)).toBeNull(); - }); - - it('TASK-131: relative ages advance in place, and a returning tab re-reads, without a reload', async () => { - // Fake the clock so the minute tick is deterministic. The fixture's age - // only moves if the page re-renders it, which is the defect being pinned. - jest.useFakeTimers(); - try { - const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString(); - mockGets([{ ...connectors[1], createdAt: fiveMinutesAgo, updatedAt: fiveMinutesAgo }]); - renderPage(); - - await screen.findByText(/added 5m$/); - - await act(async () => { jest.advanceTimersByTime(2 * 60_000); }); - expect(screen.getByText(/added 7m$/)).toBeInTheDocument(); - - const reads = () => axios.get.mock.calls.filter(([url]) => url === '/api/integrations/user/all').length; - const before = reads(); - document.dispatchEvent(new Event('visibilitychange')); - await waitFor(() => expect(reads()).toBeGreaterThan(before)); - } finally { - jest.useRealTimers(); - } - }); - - it('renders the Signal row list and opens the pending channel in the selected aside', async () => { - mockGets(); - const { container } = renderPage(); - - await screen.findByRole('button', { name: 'View Telegram' }); - expect(screen.getByText('Send /commonly-enable in your Telegram chat.')).toBeInTheDocument(); - expect(screen.getByText('Code expires in 5 min')).toBeInTheDocument(); - expect(screen.getByText('Rewire crew · linked to Ops')).toBeInTheDocument(); - // TASK-338: the unbuilt-provider placeholder row is gone from the list. - expect(container.querySelector('.v2-connector-row__names')).toBeNull(); - expect(screen.getByText('/commonly-enable abc1 23')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Copy command' })).toBeInTheDocument(); - expect(container.querySelectorAll('.v2-connector-row__glyph')).toHaveLength(2); - }); - - // TASK-156. A linked Slack stores its workspace in `teamName` - // (slackOAuthService) and never in `chatTitle`, so the row read "Slack · - // linked to Ops" beside the word Slack. The workspace name belongs on the - // detail line, not in the name slot — the name stays the platform. - describe('the row names the Slack workspace (TASK-156)', () => { - const slackRow = (config, podId = { _id: 'p2', name: 'Ops' }) => ({ - _id: 'i-slack', - installationId: 'install-slack-u1', - type: 'slack', - status: 'connected', - createdAt: new Date(Date.now() - 3_600_000).toISOString(), - updatedAt: new Date().toISOString(), - config, - podId, - }); - - it('reads the teamName when the link carries no chatTitle', async () => { - mockGets([slackRow({ teamName: 'Acme', liveRelay: false })]); - renderPage(); - - expect(await screen.findByText('Acme · linked to Ops')).toBeInTheDocument(); - expect(screen.queryByText('Slack · linked to Ops')).toBeNull(); - const row = screen.getByText('Acme · linked to Ops').closest('.v2-connector-row'); - expect(row?.querySelector('.v2-connector-row__name')?.textContent).toContain('Slack'); - }); - - it('reads the teamName on the not-linked variant too', async () => { - mockGets([slackRow({ teamName: 'Acme', liveRelay: false }, null)]); - renderPage(); - - expect(await screen.findByText('Acme · not linked to a pod')).toBeInTheDocument(); - }); - - it('keeps chatTitle winning when the link carries both', async () => { - mockGets([slackRow({ chatTitle: 'Rewire crew', teamName: 'Acme', liveRelay: false })]); - renderPage(); - - expect(await screen.findByText('Rewire crew · linked to Ops')).toBeInTheDocument(); - expect(screen.queryByText('Acme · linked to Ops')).toBeNull(); - }); - }); - - it('offers a new code from the row and aside when the Telegram code has expired', async () => { - mockGets([{ ...connectors[0], config: { connectCode: 'abc123' } }]); - axios.post.mockResolvedValue({ data: {} }); - renderPage(); - - expect((await screen.findAllByText('The enable code expired.')).length).toBeGreaterThan(0); - expect(screen.queryByText('/commonly-enable abc1 23')).toBeNull(); - fireEvent.click(screen.getAllByRole('button', { name: 'New code' })[0]); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/integrations/i-pending/connect-code', - {}, - expect.anything(), - )); - }); - - it('selects a connected channel and keeps relay and mode PATCHes in its aside', async () => { - mockGets(); - axios.patch.mockResolvedValue({ data: {} }); - renderPage(); - - fireEvent.click((await screen.findAllByRole('button', { name: 'View Slack' }))[0]); - fireEvent.click(screen.getByRole('checkbox', { name: 'Relay' })); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { config: { liveRelay: true } }, - expect.anything(), - )); - }); - - it('PATCHes the mirror mode from the selected channel aside', async () => { - mockGets([{ - ...connectors[1], - config: { chatTitle: 'Rewire crew', liveRelay: true, relayAllAgentMessages: false }, - }]); - axios.patch.mockResolvedValue({ data: {} }); - renderPage(); - - await screen.findByRole('button', { name: 'Attention' }); - fireEvent.click(screen.getByRole('button', { name: 'Mirror' })); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { config: { relayAllAgentMessages: true } }, - expect.anything(), - )); - }); - - it('uses the inline provider and pod form with every pod the user is in', async () => { - mockGets([]); - axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - const picker = screen.getByLabelText('Pod to bridge'); - const options = Array.from(picker.querySelectorAll('option')).map((option) => option.textContent); - expect(options).toContain('Rewire Live Demo'); - expect(options).toContain('Town Square'); - expect(picker.closest('.v2-connectors__aside')).not.toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Connect' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/telegram/install', - { podId: 'p1' }, - expect.anything(), - )); - }); - - it('selects Slack in the inline provider form and installs through its lifecycle verb', async () => { - mockGets([]); - axios.post.mockResolvedValue({ data: { integration: { _id: 'slack-new' } } }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - fireEvent.click(screen.getByRole('button', { name: 'Slack' })); - fireEvent.click(screen.getByRole('button', { name: 'Connect' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/slack/install', - { podId: 'p1' }, - expect.anything(), - )); - }); - - it('keeps the form open while an install claim is still in progress', async () => { - mockGets([]); - axios.post.mockResolvedValue({ data: { status: 'installing' } }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - fireEvent.click(screen.getByRole('button', { name: 'Connect' })); - 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 an install response', async () => { - mockGets([]); - axios.post.mockResolvedValue({ data: { status: 'installing', boundPodId: 'p1' } }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - fireEvent.click(screen.getByRole('button', { name: 'Connect' })); - 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 form open when the server reports an install lock', async () => { - mockGets([]); - axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress', boundPodId: 'p1' } } }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - fireEvent.click(screen.getByRole('button', { name: 'Connect' })); - 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 form open when an install lock does not name its pod', async () => { - mockGets([]); - axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress' } } }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - fireEvent.click(screen.getByRole('button', { name: 'Connect' })); - 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(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - fireEvent.click(screen.getByRole('button', { name: 'Connect' })); - const refusal = await screen.findByText(/bound to Rewire Live Demo/); - expect(refusal).toBeInTheDocument(); - // TASK-154 item 6: the old copy ('Remove it to bind a different - // pod') taught the one-pod model. The refusal is about the bind, and other - // pods reach the channel through the switches. TASK-173: the arm used to - // pin the word 'gate switches', which is this file's internal name and no - // string the page renders — so the witnessed wording went stale in the - // same direction the copy did. It now matches the label the user can - // actually look for, and the phrase is longer than the section title so a - // page that merely renders the section cannot satisfy it. - expect(screen.getByText(/through "Pods that reach this channel"/)).toBeInTheDocument(); - expect(screen.queryByText(/gate switches/)).toBeNull(); - // The verdict that narrowed this twice: "Channel details" is the aside's - // `aria-label` (:1220, :1286) and never visible text, so naming it in the - // copy sends a sighted user after a string the page does not render. That - // the phrase is an attribute and not text is witnessed where the aside is - // actually rendered (the gates scenario below); here the copy is checked, - // which is the half that can be checked in this state. - expect(refusal.textContent).not.toMatch(/Channel details/); - expect(screen.queryByText(/Remove it to bind/)).toBeNull(); - expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument(); - }); - - it('disconnects installable and legacy rows through their respective verbs', async () => { - mockGets([connectors[1]]); - axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); - const firstRender = renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Remove' })); - fireEvent.click(screen.getByRole('button', { name: /Really remove/ })); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith( - '/api/installables/slack/install', - expect.anything(), - )); - - firstRender.unmount(); - mockGets([{ ...connectors[1], installationId: undefined }]); - axios.patch.mockResolvedValue({ data: {} }); - renderPage(); - fireEvent.click(await screen.findByRole('button', { name: 'Remove' })); - fireEvent.click(screen.getByRole('button', { name: /Really remove/ })); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { isActive: false }, - expect.anything(), - )); - }); - - it('disconnects an installable Telegram connector through its lifecycle verb', async () => { - mockGets([{ ...connectors[1], installationId: 'install-telegram-u1', type: 'telegram' }]); - axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Remove' })); - fireEvent.click(screen.getByRole('button', { name: /Really remove/ })); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith( - '/api/installables/telegram/install', - expect.anything(), - )); - }); - - it('removes the retired SOON tiles and the not-yet placeholder copy', async () => { - mockGets([]); - renderPage(); - - await screen.findByRole('button', { name: /^inactive · \d+$/ }).catch(() => null); - expect(screen.queryByText(/Tell us which channel/)).toBeNull(); - expect(screen.queryByText('SOON')).toBeNull(); - }); - - it('does not offer another install control once both supported providers are present', async () => { - mockGets(); - renderPage(); - - await screen.findByRole('button', { name: 'View Telegram' }); - expect(screen.queryByRole('button', { name: 'Connect a channel' })).toBeNull(); - }); - - it('requests the credentialed Slack authorization URL and drops the opener', 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.findAllByRole('button', { name: 'Authorize in Slack' }))[0]); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/slack/authorize-url', - {}, - expect.objectContaining({ withCredentials: true }), - )); - expect(slackWindow.location.assign).toHaveBeenCalledWith('https://slack.test/oauth?state=secret'); - expect(slackWindow.opener).toBeNull(); - }); - - it('keeps an un-coded Slack authorization failure generic — the fallback, row and aside (Row C control)', async () => { - mockGets([{ - _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending', - config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' }, - }]); - axios.post.mockRejectedValue({ response: { status: 503 } }); - renderPage(); - - fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/slack/authorize-url', - {}, - expect.objectContaining({ withCredentials: true }), - )); - // A refusal with no body still gets the generic sentence — the fallback the - // named codes must not have replaced. - const shown = await screen.findAllByText('Could not begin Slack authorization. Try again in a moment.'); - expect(shown.length).toBeGreaterThan(0); - }); - - it('names a refused Slack authorize on the row that asked, not only at the page foot (Row C)', async () => { - mockGets([{ - _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending', - config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' }, - }]); - axios.post.mockRejectedValue({ - response: { status: 409, data: { code: 'slack_already_authorized', error: 'Upstream wording that a known code must not surface.' } }, - }); - renderPage(); - - fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]); - await waitFor(() => expect(axios.post).toHaveBeenCalled()); - - const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement; - expect(row).not.toBeNull(); - // The code's own copy, not the server sentence the same body also carries. - expect(within(row).getByRole('alert')).toHaveTextContent('Slack is already awaiting confirmation or connected.'); - expect(within(row).queryByText(/Upstream wording/)).toBeNull(); - // ...and beside the button in the selected row's aside. - const aside = document.querySelector('.v2-connectors__aside') as HTMLElement; - expect(within(aside).getByRole('alert')).toHaveTextContent('Slack is already awaiting confirmation or connected.'); - // Placement is the defect (Row C): the page-level slot used to be the only one. - expect(document.querySelector('.v2-connectors__error')).toBeNull(); - }); - - it('uses the server sentence when the refusal code is not one the page knows (Row C)', async () => { - mockGets([{ - _id: 'i-slack-authorize', installationId: 'install-slack-u1', type: 'slack', status: 'pending', - config: {}, podId: { _id: 'p1', name: 'Rewire Live Demo' }, - }]); - axios.post.mockRejectedValue({ - response: { status: 503, data: { code: 'slack_upstream_unavailable', error: 'Slack refused the handshake.' } }, - }); - renderPage(); - - fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]); - const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement; - await waitFor(() => expect(within(row).getByRole('alert')).toHaveTextContent('Slack refused the handshake.')); - }); - - it('names a failed Slack confirm on the row, not in the page slot (Row C)', 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.mockRejectedValue({ - response: { status: 409, data: { code: 'slack_bind_expired', error: 'Upstream wording that a known code must not surface.' } }, - }); - renderPage(); - - expect(await screen.findByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument(); - expect(screen.getByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Confirm connection' })); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith('/api/installables/slack/confirm', {}, expect.anything())); - - const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement; - expect(within(row).getByRole('alert')).toHaveTextContent('Slack authorization expired. Start again.'); - expect(document.querySelector('.v2-connectors__error')).toBeNull(); - }); - - it('moves Slack confirmation and rejection to the selected aside', 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: '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('requires a pod agent at Slack bind and sends the selected lead', 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' }, - }]); - const priorGet = axios.get.getMockImplementation(); - axios.get.mockImplementation((url) => url === '/api/pods' - ? Promise.resolve({ data: [{ - _id: 'p1', name: 'Rewire Live Demo', type: 'chat', - members: [{ _id: 'agent-1', username: 'kai', isBot: true }], - }] }) - : priorGet(url)); - axios.post.mockResolvedValue({ data: { status: 'connected' } }); - renderPage(); - - const confirm = await screen.findByRole('button', { name: 'Confirm connection' }); - expect(confirm).toBeDisabled(); - const row = (await screen.findByRole('button', { name: 'View Slack' })).closest('article') as HTMLElement; - fireEvent.click(within(row).getByRole('button', { name: 'Confirm' })); - expect(axios.post).not.toHaveBeenCalledWith('/api/installables/slack/confirm', expect.anything(), expect.anything()); - fireEvent.change(screen.getByLabelText('Agent for plain messages'), { target: { value: 'kai' } }); - expect(confirm).toBeEnabled(); - fireEvent.click(confirm); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/slack/confirm', { leadAgentUsername: 'kai' }, expect.anything(), - )); - }); - - it('does not treat a failed pod read as an empty pod at Slack confirmation', 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' }, - }]); - const priorGet = axios.get.getMockImplementation(); - axios.get.mockImplementation((url) => url === '/api/pods' - ? Promise.reject(new Error('pods unavailable')) : priorGet(url)); - renderPage(); - - expect(await screen.findByText('Commonly HQ wants to connect as @sam.')).toBeInTheDocument(); - expect(screen.queryByText('No agent is in this pod yet. Messages will reach the pod; choose a lead later.')).toBeNull(); - expect(screen.getByRole('button', { name: 'Confirm connection' })).toBeDisabled(); - }); - - it('keeps rejection reachable from the selected Slack aside', 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('consumes an opaque Slack callback result without rendering its 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(''); - expect(screen.queryByText('invalid_state')).toBeNull(); - }); - - it('shows a fixed unrequested-scope notice and consumes hostile callback query values', async () => { - window.history.replaceState( - {}, - '', - '/v2/connectors?hostedMcp=error&code=unrequested_scope&extraScope=%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E&extraScope=spoofed', - ); - mockGets([]); - renderPage(); - - expect(await screen.findByRole('alert')).toHaveTextContent( - "The provider granted access Commonly didn’t ask for, so the connection wasn’t saved.", - ); - expect(window.location.search).toBe(''); - expect(screen.queryByText(/img|spoofed/i)).toBeNull(); - expect(screen.queryByText('unrequested_scope')).toBeNull(); - }); - - it.each([ - ['unknown_entry', 'hostedMcpUnknownEntry'], - ['invalid_state', 'hostedMcpInvalidState'], - ['state_expired', 'hostedMcpInvalidState'], - ['state_consumed', 'hostedMcpInvalidState'], - ['browser_mismatch', 'hostedMcpInvalidState'], - ['issuer_mismatch', 'hostedMcpInvalidState'], - ['consent_denied', 'hostedMcpConsentDenied'], - ['vendor_error', 'hostedMcpVendorError'], - ['rate_limited', 'hostedMcpRateLimited'], - ['issuer_unreachable', 'hostedMcpIssuerUnavailable'], - ['issuer_metadata_incomplete', 'hostedMcpIssuerUnavailable'], - ['client_not_configured', 'hostedMcpClientSetup'], - ['client_registration_required', 'hostedMcpClientSetup'], - ['client_registration_unsupported', 'hostedMcpClientSetup'], - ['client_registration_refused', 'hostedMcpClientSetup'], - ['client_auth_unsupported', 'hostedMcpClientSetup'], - ['client_auth_refused', 'hostedMcpClientSetup'], - ['authorization_parameter_conflict', 'hostedMcpClientSetup'], - ['exchange_refused', 'hostedMcpExchangeRefused'], - ['exchange_unreachable', 'hostedMcpExchangeUnreachable'], - ['exchange_incomplete', 'hostedMcpExchangeIncomplete'], - ['exchange_invalid_scope', 'hostedMcpExchangeIncomplete'], - ['unrequested_scope', 'hostedMcpUnrequestedScope'], - ['authentication_required', 'hostedMcpAuthenticationRequired'], - ])('shows a fixed notice for hosted-MCP code %s and consumes the query', async (code, noticeKey) => { - window.history.replaceState({}, '', `/v2/connectors?hostedMcp=error&code=${code}&error_description=spoofed`); - mockGets([]); - renderPage(); - - expect(await screen.findByRole('alert')).toHaveTextContent(en.connectors[noticeKey]); - expect(window.location.search).toBe(''); - expect(screen.queryByText(/spoofed/)).toBeNull(); - }); - - 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('shows the server-owned provider revoke page after a hosted-MCP reconnect needs manual withdrawal', async () => { - const revokePage = 'https://linear.app/settings/security'; - window.history.replaceState( - {}, - '', - `/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=${encodeURIComponent(revokePage)}`, - ); - mockGets([{ - _id: 'i-linear', type: 'hosted-mcp', status: 'connected', - config: { entryId: 'linear', revokePage }, - }], [hostedLinearCatalogEntry()]); - renderPage(); - - const reviewLink = await screen.findByRole('link', { name: "Open Linear's connected apps" }); - expect(reviewLink).toHaveAttribute('href', revokePage); - expect(reviewLink).toHaveAttribute('target', '_blank'); - expect(reviewLink).toHaveAttribute('rel', 'noopener noreferrer'); - const notice = screen.getByRole('status'); - expect(notice).toHaveTextContent( - 'Your previous Linear account may still have an MCP grant connected. Switch to it in Linear, then revoke the MCP grant under Linear\'s connected apps.', - ); - expect(notice.closest('.v2-connector-row')).toHaveTextContent('Linear'); - expect(window.location.search).toBe(''); - }); - - it('the manual-revoke notice tells the person to switch to the account they connected before', async () => { - const revokePage = 'https://linear.app/settings/security'; - window.history.replaceState( - {}, - '', - `/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=${encodeURIComponent(revokePage)}`, - ); - mockGets([{ - _id: 'i-linear', type: 'hosted-mcp', status: 'connected', - config: { entryId: 'linear', revokePage }, - }], [hostedLinearCatalogEntry()]); - await i18nReady; - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - try { - renderPage(); - - const reviewLink = await screen.findByRole('link', { name: '打开 Linear 的已连接应用' }); - expect(reviewLink).toHaveAttribute('href', revokePage); - const notice = screen.getByRole('status'); - expect(notice).toHaveTextContent( - '你之前的 Linear 账号可能仍连着一个 MCP 授权。请在 Linear 切换到那个账号,再在其已连接应用中撤销该 MCP 授权。', - ); - expect(notice.closest('.v2-connector-row')).toHaveTextContent('Linear'); - expect(window.location.search).toBe(''); - } finally { - await act(async () => { await i18n.changeLanguage('en'); }); - } - }); - - it('warns that removing Supabase authorization disconnects everyone in the organization', async () => { - const revokePage = 'https://supabase.com/dashboard/org/_/apps'; - window.history.replaceState( - {}, - '', - `/v2/connectors?hostedMcp=connected&entryId=supabase&revokeAt=${encodeURIComponent(revokePage)}`, - ); - mockGets([{ - _id: 'i-supabase', type: 'hosted-mcp', status: 'connected', - config: { entryId: 'supabase', revokePage }, - }], [{ - ...hostedLinearCatalogEntry('i-supabase'), - installableId: 'supabase', label: 'Supabase', entryId: 'supabase', - }]); - renderPage(); - - const reviewLink = await screen.findByRole('link', { name: 'Open Supabase Authorized apps' }); - expect(reviewLink).toHaveAttribute('href', revokePage); - expect(screen.getByRole('status')).toHaveTextContent( - 'Removing Commonly under Authorized apps disconnects everyone who connected that organization.', - ); - expect(screen.getByRole('status')).not.toHaveTextContent('remove Commonly there'); - }); - - it('warns that revoking the Sentry MCP app ends every client that server holds, and does not name Commonly', async () => { - const revokePage = 'https://sentry.io/settings/account/api/authorizations/'; - window.history.replaceState( - {}, - '', - `/v2/connectors?hostedMcp=connected&entryId=sentry&revokeAt=${encodeURIComponent(revokePage)}`, - ); - mockGets([{ - _id: 'i-sentry', type: 'hosted-mcp', status: 'connected', - config: { entryId: 'sentry', revokePage }, - }], [{ - ...hostedLinearCatalogEntry('i-sentry'), - installableId: 'sentry', label: 'Sentry', entryId: 'sentry', - }]); - renderPage(); - - const reviewLink = await screen.findByRole('link', { name: "Open Sentry's connected apps" }); - expect(reviewLink).toHaveAttribute('href', revokePage); - expect(screen.getByRole('status')).toHaveTextContent( - "Revoking it ends every client that server holds for the account, not only Commonly.", - ); - expect(screen.getByRole('status')).not.toHaveTextContent('remove Commonly there'); - }); - - it('keeps the generic manual-revoke copy for an entry without its own', async () => { - const revokePage = 'https://airtable.com/account/security'; - window.history.replaceState( - {}, - '', - `/v2/connectors?hostedMcp=connected&entryId=airtable&revokeAt=${encodeURIComponent(revokePage)}`, - ); - mockGets([{ - _id: 'i-airtable', type: 'hosted-mcp', status: 'connected', - config: { entryId: 'airtable', revokePage }, - }], [{ - ...hostedLinearCatalogEntry('i-airtable'), - installableId: 'airtable', label: 'Airtable', entryId: 'airtable', - }]); - renderPage(); - - expect(await screen.findByRole('link', { name: "Open Airtable's connected apps" })).toHaveAttribute('href', revokePage); - expect(screen.getByRole('status')).toHaveTextContent( - 'Your previous Airtable account may still have Commonly connected. Switch to it on Airtable, then remove Commonly there.', - ); - }); - - it('does not turn a callback query into a provider link unless it matches the row', async () => { - window.history.replaceState( - {}, - '', - '/v2/connectors?hostedMcp=connected&entryId=linear&revokeAt=https%3A%2F%2Fevil.example%2F', - ); - mockGets([{ - _id: 'i-linear', type: 'hosted-mcp', status: 'connected', - config: { entryId: 'linear', revokePage: 'https://linear.app/settings/security' }, - }], [hostedLinearCatalogEntry()]); - renderPage(); - - const linearDescription = await screen.findByText('Read Linear issues and projects.'); - expect(linearDescription.closest('.v2-connector-row')).toBeInTheDocument(); - await waitFor(() => expect(window.location.search).toBe('')); - expect(screen.queryByRole('link', { name: "Open Linear's connected apps" })).toBeNull(); - }); - - it('shows the error reconnect action and retains the separate removal action', async () => { - mockGets([{ - _id: 'i-slack-error', installationId: 'install-slack-u1', type: 'slack', status: 'error', - config: { chatTitle: 'Commonly HQ', liveRelay: true }, - podId: { _id: 'p1', name: 'Rewire Live Demo' }, - }]); - axios.post.mockRejectedValue({ response: { status: 503 } }); - renderPage(); - - expect((await screen.findAllByText('The connection dropped.')).length).toBeGreaterThan(0); - fireEvent.click((await screen.findAllByRole('button', { name: 'Authorize in Slack' }))[0]); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/slack/authorize-url', - {}, - expect.objectContaining({ withCredentials: true }), - )); - fireEvent.click(screen.getByRole('button', { name: 'Remove' })); - expect(screen.getByRole('button', { name: /Really remove/ })).toBeInTheDocument(); - }); - - it('names the reason a connector needs attention when the server sent one', async () => { - // The row's line is the only place a person sees why relaying stopped. A - // classified permanent failure writes `errorMessage`; the generic sentence - // is the fallback for everything else, not a replacement for it. - const reason = 'Telegram stopped delivering: the bot was blocked or removed from this chat.'; - mockGets([{ - _id: 'i-tg-error', installationId: 'install-tg-u1', type: 'telegram', status: 'error', errorMessage: reason, - // The flag is what makes the message readable to a person: it is set by - // connectorDeliveryFailureService, the writer that owns the pair. - errorMessageUserFacing: true, - config: { chatTitle: 'Ops chat', liveRelay: true }, - podId: { _id: 'p1', name: 'Rewire Live Demo' }, - }]); - renderPage(); - - expect((await screen.findAllByText(reason)).length).toBeGreaterThan(0); - expect(screen.queryByText('The connection dropped.')).toBeNull(); - }); - - it('does not print a provider error text in a connector row, only a message written for a person', async () => { - // `Integration.errorMessage` has an older writer: externalFeedService copies - // a provider's response, or a raw exception message, into it for the `x` and - // `instagram` rows. Those rows reach this same error branch, so a value in - // the field is not permission to render it — `errorMessageUserFacing` is, - // and it is absent here. The row still shows the generic sentence. - const raw = 'connect ECONNREFUSED 10.4.4.7:443'; - mockGets([{ - _id: 'i-x-error', installationId: 'install-x-u1', type: 'x', status: 'error', errorMessage: raw, - config: { chatTitle: 'Feed' }, - podId: { _id: 'p1', name: 'Rewire Live Demo' }, - }]); - renderPage(); - - expect((await screen.findAllByText('The connection dropped.')).length).toBeGreaterThan(0); - expect(screen.queryByText(raw)).toBeNull(); - }); - - it('derives the installable lifecycle target from the connector row type', () => { - expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install'); - }); - // D8 Phase 2: rows keyed by the capability catalog (D1/D2), the aside's - // gate list (D4), and the not-linked row (#1551). - describe('catalog rows', () => { - // TASK-155. The walk found that adding a channel with a live row selected - // left the panel on that row: the row's detail kept the aside and the form - // was rendered beside the list instead, so the picker appeared somewhere - // other than the panel the page uses for the thing you are doing. - const asidePanel = () => document.querySelector('.v2-connectors__aside') as HTMLElement; - - it('TASK-155: adding a channel moves the panel to the picker', async () => { - mockCatalog([ - entry(), - entry({ installableId: 'slack', label: 'Slack', installation: { status: 'active', boundPodId: 'p1' }, integration: liveIntegration() }), - ]); - renderPage(); - fireEvent.click(await screen.findByRole('button', { name: 'View Slack' })); - expect(within(asidePanel()).getByText('What the channel sees')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'Add' })); - - expect(within(asidePanel()).getByLabelText('Pod to bridge')).toBeInTheDocument(); - expect(within(asidePanel()).queryByText('What the channel sees')).toBeNull(); - // One form, not two: the left-column copy the walk could not see is gone. - expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1); - }); - - it('TASK-155: "Connect a channel" moves the panel while a row is selected', async () => { - mockCatalog([ - entry(), - entry({ installableId: 'slack', label: 'Slack', installation: { status: 'active', boundPodId: 'p1' }, integration: liveIntegration() }), - ]); - renderPage(); - fireEvent.click(await screen.findByRole('button', { name: 'View Slack' })); - - fireEvent.click(screen.getByRole('button', { name: 'Connect a channel' })); - - expect(within(asidePanel()).getByLabelText('Pod to bridge')).toBeInTheDocument(); - expect(screen.getAllByLabelText('Pod to bridge')).toHaveLength(1); - }); - - it('TASK-155: selecting a row while the form is open closes the form', async () => { - mockCatalog([ - entry(), - entry({ installableId: 'slack', label: 'Slack', installation: { status: 'active', boundPodId: 'p1' }, integration: liveIntegration() }), - ]); - renderPage(); - fireEvent.click(await screen.findByRole('button', { name: 'Add' })); - expect(within(asidePanel()).getByLabelText('Pod to bridge')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button', { name: 'View Slack' })); - - // The form wins the aside while it is open, so the row click has to close - // it — otherwise this is the walk's symptom pointed the other way - // (connector-ops 74623 item 1). - expect(within(asidePanel()).getByText('What the channel sees')).toBeInTheDocument(); - expect(screen.queryByLabelText('Pod to bridge')).toBeNull(); - }); - - it('TASK-155: the picker takes focus when the form opens', async () => { - mockCatalog([entry()]); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - - // At 390 the panel is below the fold of the page's scroller; focus is - // what scrolls the picker into view (connector-ops 74623 item 2). - expect(screen.getByLabelText('Pod to bridge')).toHaveFocus(); - }); - - it('TASK-155: the picker defaults to a room, never a personal pod', async () => { - mockCatalog([entry()], [], [ - { _id: 'p-scout', name: 'Scout (Default)', type: 'agent-room', members: [] }, - { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [] }, - { _id: 'p2', name: 'Ops', type: 'team', members: [] }, - ]); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - - const picker = screen.getByLabelText('Pod to bridge') as HTMLSelectElement; - expect(picker.value).toBe('p1'); - // A DM stays selectable: it is a legal target, just never the default. - expect(Array.from(picker.options).map((option) => option.textContent)).toContain('Scout (Default)'); - }); - - const entry = (over = {}) => ({ - installableId: 'telegram', - label: 'Telegram', - // Mirror of the provider manifest's copy, which the server sends as the - // entry's description. The source pin is backend manifestReadiness. - description: 'Link your Telegram chat to Commonly — every pod you turn on reaches it.', - available: true, - installation: null, - integration: null, - ...over, - }); - const liveIntegration = (over = {}) => ({ - _id: 'i-live', - installationId: 'install-slack-u1', - type: 'slack', - status: 'connected', - scope: 'user', - isActive: true, - createdAt: new Date(Date.now() - 3_600_000).toISOString(), - updatedAt: new Date().toISOString(), - config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' } } }, - podId: 'p1', - ...over, - }); - const mockCatalog = (installables, list = [], pods = [ - { _id: 'p1', name: 'Rewire Live Demo', type: 'chat', members: [{ _id: 'b1', username: 'vale', isBot: true }] }, - { _id: 'p2', name: 'Ops', type: 'team', members: [] }, - ]) => { - axios.get.mockImplementation((url) => { - if (url === '/api/integrations/user/all') return Promise.resolve({ data: list }); - if (url === '/api/installables') return Promise.resolve({ data: { installables } }); - if (url === '/api/pods') return Promise.resolve({ data: pods }); - return Promise.resolve({ data: [] }); - }); - }; - - it('counts connected channels in the page head, not catalogue options', async () => { - mockCatalog([ - entry(), - entry({ - installableId: 'slack', label: 'Slack', - installation: { status: 'active', boundPodId: 'p1' }, - integration: liveIntegration(), - }), - ]); - const { container } = renderPage(); - - await waitFor(() => expect(container.querySelector('.v2-connectors__header .v2-page-head__meta')?.textContent).toBe('1 connected')); - }); - - it('omits the connected count when the catalogue has no live channels', async () => { - mockCatalog([entry(), entry({ installableId: 'discord', label: 'Discord' })]); - const { container } = renderPage(); - - await screen.findByRole('button', { name: 'View Telegram' }); - expect(container.querySelector('.v2-connectors__header .v2-page-head__meta')).toBeNull(); - }); - - it('TASK-131: a catalog failure prints the generic sentence, not the raw exception that wrote the row', async () => { - // The catalogue half reads `InstallableInstallation.errorMessage`, whose - // projection-failure writer stores `error.message` verbatim. Same rule as - // the pod-scoped half: a value in the field is not permission to render - // it, and the flag is absent on this fixture. - const raw = 'connect ECONNREFUSED 10.4.4.7:443'; - mockCatalog([ - entry({ installation: { status: 'error', errorMessage: raw } }), - ]); - renderPage(); - - expect((await screen.findAllByText('Setup didn’t finish.')).length).toBeGreaterThan(0); - expect(screen.queryByText(raw)).toBeNull(); - }); - - it('TASK-131: a catalog message written for a person still renders verbatim', async () => { - // The reconciler's own reasons are written for the person reading this row, - // and the flag the builder sets beside them is what keeps them readable once - // the generic sentence became the fallback. - const reason = REASON_CHANNEL_GONE; - mockCatalog([ - entry({ installation: { status: 'error', errorMessage: reason, errorMessageUserFacing: true } }), - ]); - renderPage(); - - expect((await screen.findAllByText(reason)).length).toBeGreaterThan(0); - expect(screen.queryByText('Setup didn’t finish.')).toBeNull(); - }); - - // Tools plan (option A, two lists): a tool Installable shares the - // catalogue response but belongs to the Tools page, never to this one. - it('a tool Installable in the catalogue never renders as a channel row', async () => { - mockCatalog([ - { - installableId: 'github', - list: 'tools', - label: 'GitHub', - description: 'Issues and pull requests.', - available: true, - broker: { id: 'commonly-grant-broker' }, - tools: [{ name: 'github.list_issues', requiredWriteMode: 'read', irreversible: false }], - connections: [], - installation: null, - integration: null, - }, - entry({ list: 'channels' }), - ]); - renderPage(); - - expect(await screen.findByText('Link your Telegram chat to Commonly — every pod you turn on reaches it.')).toBeInTheDocument(); - expect(screen.queryByText('GitHub')).toBeNull(); - expect(screen.queryByText('Issues and pull requests.')).toBeNull(); - expect(screen.queryByRole('button', { name: 'View GitHub' })).toBeNull(); - expect(screen.getAllByRole('button', { name: 'Add' })).toHaveLength(1); - }); - - it('keeps tool-only connections out of Channels and refuses an unknown type as a row title', async () => { - mockCatalog([entry()], [ - { - // Real hosted-MCP rows have no installationId; filter them by type. - _id: 'i-hosted-linear', type: 'hosted-mcp', status: 'connected', - config: { entryId: 'linear' }, - }, - { - _id: 'i-github-app', installationId: 'github-install', type: 'github-app', status: 'connected', - }, - { - _id: 'i-unknown-channel', type: 'custom-messaging-platform', status: 'connected', - }, - ]); - renderPage(); - - await waitFor(() => expect(screen.getByRole('button', { name: 'View Telegram' })).toBeInTheDocument()); - expect(screen.queryByRole('button', { name: /hosted-mcp|github-app/i })).toBeNull(); - expect(screen.queryByRole('button', { name: 'View Custom Messaging Platform' })).toBeNull(); - expect(screen.queryByText('hosted-mcp')).toBeNull(); - expect(screen.queryByText('github-app')).toBeNull(); - expect(screen.queryByText('Hosted tool')).toBeNull(); - expect(screen.queryByText('GitHub App')).toBeNull(); - expect(screen.queryByText('custom-messaging-platform')).toBeNull(); - }); - - // TASK-024. Discord is a shipping connector (routes/discord.ts: install - // link, callback, binding, uninstall) that read as "we don't build this" - // because its manifest declared no readiness(), which is what the catalog - // filters on. Once it declares one the catalog owns every claim about it: - // configured -> a connectable row, not configured -> the not-enabled row - // that already exists for slack. The not-yet row must stop covering it. - const glyphPath = (type: string): string | null => { - const { container } = render(); - return container.querySelector('svg path')?.getAttribute('d') || null; - }; - - it('TASK-338: dead rows collapse under one inactive fold below the live rows', async () => { - mockCatalog([ - entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }), - entry({ - installableId: 'telegram', - label: 'Telegram', - available: true, - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration({ _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }), - }), - ]); - const { container } = renderPage(); - const fold = await screen.findByRole('button', { name: 'inactive · 1' }); - expect(fold).toHaveAttribute('aria-expanded', 'false'); - const rows = container.querySelector('.v2-connectors__rows') as HTMLElement; - expect(rows.firstElementChild).toHaveTextContent('Telegram'); - expect(screen.queryByText('Not offered on this instance.')).toBeNull(); - fireEvent.click(fold); - expect(screen.getByText('Not offered on this instance.')).toBeInTheDocument(); - }); - - it('renders the zh-CN catalog copy for a channel row and the server text for an unmapped one', async () => { - mockCatalog([ - entry({ list: 'channels' }), - entry({ installableId: 'acme-chat', label: 'Acme Chat', description: 'Acme chat server copy.' }), - ]); - await i18nReady; - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - try { - renderPage(); - expect(await screen.findByText('把你的 Telegram 聊天接入 Commonly,你开启的每个 Pod 都能把消息发到这里。')).toBeInTheDocument(); - expect(screen.getByText('Acme chat server copy.')).toBeInTheDocument(); - } finally { - await act(async () => { await i18n.changeLanguage('en'); }); - } - }); - - it('describes Discord only through the catalog, never as a provider we have not built', async () => { - mockCatalog([ - entry({ installableId: 'discord', label: 'Discord', available: false, unavailableReason: 'not_configured' }), - ]); - renderPage(); - - await openInactiveFold(); - const notEnabled = (await screen.findByText('Not offered on this instance.')).closest('.v2-connector-row'); - expect(notEnabled).toHaveClass('v2-connector-row--not-enabled'); - expect(notEnabled?.textContent).toContain('Discord'); - expect(notEnabled?.querySelector('svg path')?.getAttribute('d')).toBe(glyphPath('discord')); - expect(screen.queryByText(/Tell us which channel/)).toBeNull(); - }); - - it('offers a rostered provider with Add, and an unmapped one inherits no onboarding sentence', async () => { - // Slack's line is in the map; Discord's is not. The point of the map is - // that Discord renders NO sentence rather than Slack's. - mockCatalog([ - entry({ installableId: 'discord', label: 'Discord', available: true, offered: true }), - entry({ installableId: 'slack', label: 'Slack', available: true, offered: true }), - ]); - renderPage(); - - const discord = (await screen.findByText('Discord')).closest('.v2-connector-row') as HTMLElement; - expect(discord.textContent).not.toContain('one click in your workspace'); - expect(discord.textContent).not.toContain('one message'); - expect(within(discord).getByRole('button', { name: 'Add' })).toBeInTheDocument(); - - const slack = screen.getByText('Slack').closest('.v2-connector-row') as HTMLElement; - expect(slack.textContent).toContain('one click in your workspace'); - - expect(screen.queryByText(/Tell us which channel/)).toBeNull(); - }); - - it('does not list a provider this instance does not offer (D215)', async () => { - // Discord on commonly.me: usable keys, no builtin Installable row, never - // walked. A stranger must not see it, not even in the inactive fold. - mockCatalog([ - entry(), - entry({ installableId: 'discord', label: 'Discord', available: true, offered: false }), - ]); - renderPage(); - - await screen.findByText('Telegram'); - // The fold only exists when a dead row does, so its absence is the - // assertion; a collapsed fold would hide a listed Discord from queryByText. - expect(screen.queryByRole('button', { name: /^inactive · \d+$/ })).toBeNull(); - expect(screen.queryByText('Discord')).toBeNull(); - }); - - it('keeps an installed but no-longer-offered provider as not connectable, with no Add anywhere', async () => { - // The ruled middle state, now reachable only by someone who installed it - // before the roster row went away: the row states the state and offers no - // action. Before #1826 the row rendered an Add whose click ended in 404 - // installable_not_found. - mockCatalog([ - entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, installation: { status: 'active', boundPodId: 'p1' } }), - ]); - renderPage(); - - await openInactiveFold(); - const roster = (await screen.findByText('Unavailable here.')).closest('.v2-connector-row') as HTMLElement; - expect(roster).toHaveClass('v2-connector-row--not-enabled'); - expect(roster.textContent).toContain('Discord'); - expect(roster.querySelector('svg path')?.getAttribute('d')).toBe(glyphPath('discord')); - expect(within(roster).queryByRole('button', { name: 'Add' })).toBeNull(); - // The only action left is the Ask link the not-enabled row shares; there is - // no Add and no connect verb. `row.notEnabled` drives that link, so both - // no-action catalog states carry it. - const actions = roster.querySelectorAll('.v2-connector-row__action'); - expect(actions).toHaveLength(1); - expect(actions[0].textContent).toBe('Ask'); - - // The eyebrow must not contradict the line under it. `notEnabled` drives - // this row's class and Ask link, so it also drove the kicker: the keys-set - // render read "not enabled" directly above "Not connectable yet." — asking - // an operator for credentials the instance already has (#1826). - // TASK-338: a dead row carries no state word in its kicker; the fold and - // the line say it. - expect(roster.querySelector('.v2-connector-row__kicker')).toBeEmptyDOMElement(); - - // The Add verb itself must be gone, not merely unused: an offered provider - // is the only thing that may produce it, and there is none in this catalog. - expect(screen.queryByRole('button', { name: 'Add' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Connect a channel' })).toBeNull(); - expect(screen.queryByText(/Tell us which channel/)).toBeNull(); - }); - - it('keeps a no-longer-offered provider listed when the person has only an integration row', async () => { - // The third arm of the D215 filter: a channel connected before the roster - // row went away can leave an integration with no installation. Dropping it - // from the list would hide a live connection from its owner. - mockCatalog([ - entry({ installableId: 'discord', label: 'Discord', available: true, offered: false, integration: liveIntegration() }), - ]); - renderPage(); - - await openInactiveFold(); - const row = (await screen.findByText('Unavailable here.')).closest('.v2-connector-row') as HTMLElement; - expect(row.textContent).toContain('Discord'); - }); - - it('renders an unavailable provider with a state line and an available one with Add', async () => { - mockCatalog([ - entry(), - entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }), - ]); - axios.post.mockResolvedValue({ data: { status: 'installing' } }); - renderPage(); - - await openInactiveFold(); - expect(await screen.findByText('Not offered on this instance.')).toBeInTheDocument(); - // This commit's own copy expectations are NOT merged: they asserted the - // one-pod Telegram sentence TASK-154 reversed, and the absence of the - // "ask your operator" detail — which #1557 landed AFTER the 09-04 ruling - // this commit cites, and which main's tests assert is present. What - // survives from it is the CODE (an offered provider is the only thing that - // may draw Add; each unconnectable provider gets its own state line), - // which is what these assertions exercise. - expect(screen.getByText('ask your operator')).toBeInTheDocument(); - expect(screen.getByText('Link your Telegram chat to Commonly — every pod you turn on reaches it.')).toBeInTheDocument(); - expect(screen.getByText(/not connected/)).toBeInTheDocument(); - expect(screen.queryByText('not_configured')).toBeNull(); - const ask = screen.getAllByRole('link', { name: 'Ask' }).find((link) => link.closest('.v2-connector-row')?.classList.contains('v2-connector-row--not-enabled')); - expect(ask).toBeDefined(); - expect(ask).toHaveAttribute('href', 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request'); - expect(ask).toHaveClass('v2-connector-row__action--secondary'); - expect(ask.closest('.v2-connector-row')).toHaveClass('v2-connector-row--not-enabled'); - expect(ask.closest('.v2-connector-row')?.querySelector('.v2-connector-row__detail')).toHaveTextContent('ask your operator'); - const choosePod = screen.getAllByRole('button', { name: 'Add' }); - expect(choosePod).toHaveLength(1); - fireEvent.click(choosePod[0]); - expect(screen.getByRole('button', { name: 'Telegram' })).toHaveClass('v2-connectors__provider--selected'); - expect(screen.queryByRole('button', { name: 'Slack' })).toBeNull(); - fireEvent.click(document.querySelector('.v2-connectors__create')); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/telegram/install', - { podId: 'p1' }, - expect.anything(), - )); - }); - - // TASK-140: a not-enabled row has no connection at all, so its pod slot - // said `no pod` — false about a row whose blocker is the instance itself, - // and it reads as a missing pod rather than a disabled provider. - it('TASK-140: a not-enabled row names its blocker in the kicker, never "no pod"', async () => { - mockCatalog([ - entry({ installableId: 'slack', label: 'Slack', available: false, unavailableReason: 'not_configured' }), - entry({ - installableId: 'telegram', - label: 'Telegram', - available: true, - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration({ _id: 'i-tg', installationId: 'install-telegram-u1', type: 'telegram' }), - }), - ]); - renderPage(); - - await openInactiveFold(); - const ask = (await screen.findAllByRole('link', { name: 'Ask' })) - .find((link) => link.closest('.v2-connector-row')?.classList.contains('v2-connector-row--not-enabled')); - expect(ask).toBeDefined(); - // TASK-338: the blocker is the line; the kicker carries no state word. - expect(ask?.closest('.v2-connector-row')?.querySelector('.v2-connector-row__kicker')).toBeEmptyDOMElement(); - expect(ask?.closest('.v2-connector-row')).toHaveTextContent('Not offered on this instance.'); - expect(screen.queryByText('no pod')).toBeNull(); - - // Positive control: the row that DOES carry a connection still names its - // pod, so the assertion above cannot pass by every kicker reading alike. - const linked = (await screen.findAllByRole('button', { name: 'View Telegram' }))[0]; - expect(linked.querySelector('.v2-connector-row__kicker')?.textContent).toMatch(/^Rewire Live Demo · /); - }); - - it('labels the row step separately from the final connect action', async () => { - mockCatalog([entry()]); - renderPage(); - - const choosePod = await screen.findByRole('button', { name: 'Add' }); - expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); - - fireEvent.click(choosePod); - expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); - }); - - it('gives a zero-pod owner a path to create a pod before connecting', async () => { - axios.get.mockImplementation((url) => { - if (url === '/api/integrations/user/all') return Promise.resolve({ data: [] }); - if (url === '/api/installables') return Promise.resolve({ data: { installables: [entry()] } }); - if (url === '/api/pods') return Promise.resolve({ data: [] }); - return Promise.resolve({ data: [] }); - }); - render( - - - - } /> - pods list} /> - - - , - ); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - expect(screen.queryByLabelText('Pod to bridge')).toBeNull(); - expect(screen.queryByRole('group', { name: 'Channel provider' })).toBeNull(); - expect(screen.getByText('No pod yet. Create one, then come back to connect a channel.')).toBeInTheDocument(); - expect(screen.queryByText('Choose a channel and the pod it should join.')).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: 'Create a pod' })); - expect(await screen.findByText('pods list')).toBeInTheDocument(); - }); - - it('keeps the existing picker visible while pod membership is still loading', async () => { - axios.get.mockImplementation((url) => { - if (url === '/api/integrations/user/all') return Promise.resolve({ data: [] }); - if (url === '/api/installables') return Promise.resolve({ data: { installables: [entry()] } }); - if (url === '/api/pods') return new Promise(() => {}); - return Promise.resolve({ data: [] }); - }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - expect(screen.getByRole('group', { name: 'Channel provider' })).toBeInTheDocument(); - expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument(); - expect(screen.queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull(); - expect(screen.queryByRole('button', { name: 'Create a pod' })).toBeNull(); - }); - - it('keeps the existing picker visible when pod membership cannot be read', async () => { - axios.get.mockImplementation((url) => { - if (url === '/api/integrations/user/all') return Promise.resolve({ data: [] }); - if (url === '/api/installables') return Promise.resolve({ data: { installables: [entry()] } }); - if (url === '/api/pods') return Promise.reject(new Error('pods unavailable')); - return Promise.resolve({ data: [] }); - }); - renderPage(); - - fireEvent.click(await screen.findByRole('button', { name: 'Connect a channel' })); - expect(screen.getByRole('group', { name: 'Channel provider' })).toBeInTheDocument(); - expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument(); - expect(screen.queryByText('No pod yet. Create one, then come back to connect a channel.')).toBeNull(); - expect(screen.queryByRole('button', { name: 'Create a pod' })).toBeNull(); - }); - - it('shows Setting up… without a control while the claim is fresh, and Cancel once it is stale', async () => { - mockCatalog([entry({ installation: { status: 'installing', claimedAt: new Date().toISOString(), components: [] } })]); - const fresh = renderPage(); - expect(await screen.findByText('Setting up…')).toBeInTheDocument(); - expect(screen.getByText('waiting for the server')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull(); - fresh.unmount(); - - mockCatalog([entry({ - installation: { status: 'installing', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] }, - })]); - axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } }); - renderPage(); - expect(await screen.findByText('Setup is taking longer than it should.')).toBeInTheDocument(); - fireEvent.click(screen.getAllByRole('button', { name: 'Cancel' })[0]); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything())); - }); - - it('offers Retry on an error parent, posting the bound pod, and Remove in the aside', async () => { - mockCatalog([entry({ - // The reconciler's own reason, and since TASK-131 it travels with the flag - // that says so: the row's line renders a message only when a writer - // declared it was written for a person. - installation: { - status: 'error', - errorMessage: REASON_CHANNEL_GONE, - errorMessageUserFacing: true, - boundPodId: 'p2', - updatedAt: new Date().toISOString(), - components: [], - }, - })]); - axios.post.mockResolvedValue({ data: { status: 'installing' } }); - axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } }); - renderPage(); - - expect((await screen.findAllByText(REASON_CHANNEL_GONE)).length).toBeGreaterThan(0); - expect(screen.getByText('retry, or remove it')).toBeInTheDocument(); - fireEvent.click(screen.getAllByRole('button', { name: 'Retry' })[0]); - await waitFor(() => expect(axios.post).toHaveBeenCalledWith( - '/api/installables/telegram/install', - { podId: 'p2' }, - expect.anything(), - )); - fireEvent.click(screen.getByRole('button', { name: 'Remove' })); - fireEvent.click(screen.getByRole('button', { name: /Really remove/ })); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything())); - }); - - it('offers Retry remove once an uninstalling claim is stale', async () => { - mockCatalog([entry({ - installation: { status: 'uninstalling', claimedAt: new Date(Date.now() - INSTALL_LOCK_TTL_MS - 1000).toISOString(), components: [] }, - })]); - axios.delete.mockResolvedValue({ data: { status: 'uninstalling' } }); - renderPage(); - - expect(await screen.findByText('Removal is taking longer than it should.')).toBeInTheDocument(); - fireEvent.click(screen.getAllByRole('button', { name: 'Retry remove' })[0]); - await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/installables/telegram/install', expect.anything())); - }); - - it('shows a paused parent with its reason and no owner control', async () => { - mockCatalog([entry({ - installableId: 'slack', - label: 'Slack', - installation: { status: 'paused', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration({ config: { chatTitle: 'Rewire crew', liveRelay: true, adminPause: { reason: 'Spam report under review.', at: new Date(Date.now() - 120_000).toISOString() } } }), - })]); - renderPage(); - - expect((await screen.findAllByText('Paused by an administrator. Spam report under review.')).length).toBeGreaterThan(0); - expect(screen.getByText(/paused 2m$/)).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Remove' })).toBeNull(); - expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); - expect(screen.queryByRole('checkbox', { name: 'Relay' })).toBeNull(); - }); - - it('treats a missing projection under an active parent as a Retry row', async () => { - mockCatalog([entry({ installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, integration: null })]); - axios.post.mockResolvedValue({ data: { status: 'installing' } }); - renderPage(); - - expect((await screen.findAllByText('The channel record is gone.')).length).toBeGreaterThan(0); - expect(screen.getByText('retry rebuilds it')).toBeInTheDocument(); - expect(screen.getAllByRole('button', { name: 'Retry' }).length).toBeGreaterThan(0); - }); - - it('renders the not-linked row and expands the pod list with Make active', async () => { - mockCatalog([entry({ - installableId: 'slack', - label: 'Slack', - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration({ podId: null, config: { chatTitle: 'Rewire crew', liveRelay: true, gates: {} } }), - })]); - axios.patch.mockResolvedValue({ data: {} }); - renderPage(); - - expect(await screen.findByText('Rewire crew · not linked to a pod')).toBeInTheDocument(); - expect(screen.getByText('your messages have nowhere to go')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: 'Pick a pod' })); - expect(screen.getByText('Pick where your messages go')).toBeInTheDocument(); - expect(screen.queryByText('active')).toBeNull(); - const makeActive = screen.getAllByRole('button', { name: 'Make active' }); - expect(makeActive).toHaveLength(2); - fireEvent.click(makeActive[1]); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { podId: 'p2' }, - expect.anything(), - )); - }); - - // TASK-164: the gate line was the last raw English relative time on this page — - // `since ${relativeTime(...)}` built its own sentence and no key could reach it. - it('the gate line reads in zh-CN, not English (TASK-164)', async () => { - mockCatalog([entry({ - installableId: 'slack', - label: 'Slack', - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration(), - })]); - await i18nReady; - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - try { - renderPage(); - const line = await screen.findByText(/前起$/); - expect(line.textContent).toMatch(/^(刚刚|\d+(分钟|小时|天)前)起$/); - expect(screen.queryByText(/^since /)).toBeNull(); - } finally { - await act(async () => { await i18n.changeLanguage('en'); }); - } - }); - - it('lists every pod with its gate, marks the active pod, and writes the whole gates map on a switch', async () => { - mockCatalog([entry({ - installableId: 'slack', - label: 'Slack', - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration(), - })]); - axios.patch.mockResolvedValue({ data: {} }); - renderPage(); - - expect(await screen.findByText('Rewire crew · linked to Rewire Live Demo')).toBeInTheDocument(); - expect(screen.getByText('Pods that reach this channel')).toBeInTheDocument(); - // The anchors, measured rather than assumed: the aside that - // holds that section is reachable by its ACCESSIBLE NAME and never as - // text. A copy that names it sends a sighted user hunting, which is the - // defect TASK-173 was opened for — so the distinction is witnessed here, - // where the aside renders, and the copy is checked against it above. - expect(screen.getByLabelText('Channel details')).toBeInTheDocument(); - expect(screen.queryAllByText(/^Channel details$/)).toHaveLength(0); - expect(screen.getByText('active')).toBeInTheDocument(); - expect(screen.getByText('off')).toBeInTheDocument(); - expect(screen.getByText(/^since /)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('switch', { name: 'Relay Ops' })); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { config: { gates: { - p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, - p2: { enabled: true, since: expect.any(String) }, - } } }, - expect.anything(), - )); - }); - - it('drops a gate for a pod the owner has left before writing the map', async () => { - mockCatalog([entry({ - installableId: 'slack', - label: 'Slack', - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration({ - config: { chatTitle: 'Rewire crew', liveRelay: true, gates: { - p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, - pLeft: { enabled: true, since: '2026-08-01T00:00:00.000Z' }, - } }, - }), - })]); - axios.patch.mockResolvedValue({ data: {} }); - renderPage(); - - await screen.findByText('Pods that reach this channel'); - fireEvent.click(screen.getByRole('switch', { name: 'Relay Ops' })); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { config: { gates: { - p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, - p2: { enabled: true, since: expect.any(String) }, - } } }, - expect.anything(), - )); - }); - - it('keeps mode and lead overrides behind the pod name and makes another pod active', async () => { - mockCatalog([entry({ - installableId: 'slack', - label: 'Slack', - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration(), - })]); - axios.patch.mockResolvedValue({ data: {} }); - renderPage(); - - await screen.findByText('Pods that reach this channel'); - expect(screen.queryByRole('button', { name: 'Make active' })).toBeNull(); - fireEvent.click(screen.getByRole('button', { name: /Ops/ })); - const more = within(document.querySelector('.v2-connector-gate__more')); - fireEvent.click(more.getByRole('button', { name: 'Mirror' })); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { config: { gates: { - p1: { enabled: true, since: '2026-09-01T00:00:00.000Z' }, - p2: { enabled: false, mode: 'mirror', since: expect.any(String) }, - } } }, - expect.anything(), - )); - const makeActive = screen.getByRole('button', { name: 'Make active' }); - await waitFor(() => expect(makeActive).not.toBeDisabled()); - fireEvent.click(makeActive); - await waitFor(() => expect(axios.patch).toHaveBeenCalledWith( - '/api/integrations/i-live', - { podId: 'p2' }, - expect.anything(), - )); - }); - - it('keys an installable row by the catalog, not by its legacy listing', async () => { - mockCatalog( - [entry({ - installableId: 'slack', - label: 'Slack', - installation: { status: 'active', updatedAt: new Date().toISOString(), components: [] }, - integration: liveIntegration(), - })], - [{ ...connectors[1] }], - ); - renderPage(); - - expect(await screen.findAllByRole('button', { name: 'View Slack' })).toHaveLength(1); - }); - }); -}); diff --git a/frontend/src/v2/__tests__/V2InactiveFold.test.tsx b/frontend/src/v2/__tests__/V2InactiveFold.test.tsx deleted file mode 100644 index 76bc621aff..0000000000 --- a/frontend/src/v2/__tests__/V2InactiveFold.test.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import i18n, { i18nReady } from '../../i18n'; -import V2InactiveFold from '../components/V2InactiveFold'; - -beforeAll(async () => { await i18nReady; }); -afterEach(async () => { await act(async () => { await i18n.changeLanguage('en'); }); }); - -test('inactive rows are hidden until the shared count fold opens', async () => { - await act(async () => { await i18n.changeLanguage('en'); }); - render(Revoked grantExpired grant); - - const toggle = screen.getByRole('button', { name: 'inactive · 2' }); - expect(toggle).toHaveAttribute('aria-expanded', 'false'); - expect(screen.queryByText('Revoked grant')).not.toBeInTheDocument(); - - fireEvent.click(toggle); - expect(toggle).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByText('Revoked grant')).toBeInTheDocument(); - expect(screen.getByText('Expired grant')).toBeInTheDocument(); - - fireEvent.click(toggle); - expect(screen.queryByText('Revoked grant')).not.toBeInTheDocument(); -}); - -test('the count label follows the Chinese locale', async () => { - await act(async () => { await i18n.changeLanguage('zh-CN'); }); - render(Inactive item); - expect(screen.getByRole('button', { name: '非活跃 · 9' })).toBeInTheDocument(); -}); diff --git a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx index 33d0f12f25..12beb26e6f 100644 --- a/frontend/src/v2/__tests__/v2-copy-budget.test.tsx +++ b/frontend/src/v2/__tests__/v2-copy-budget.test.tsx @@ -14,7 +14,13 @@ describe('page head copy budget', () => { expect(app).not.toMatch(/description=\{description\}|description: string/); expect(source('components/V2AgentBYO.tsx')).not.toMatch(/eyebrow=|description=/); expect(source('components/V2ArtifactsPage.tsx')).not.toContain('v2-artifacts__meta'); - for (const file of ['components/V2ActivityPage.tsx', 'components/V2ConnectorsPage.tsx', 'components/V2Thread.tsx']) { + for (const file of [ + 'components/V2ActivityPage.tsx', + 'connectors/ConnectorsPage.tsx', + 'connectors/ChannelPanel.tsx', + 'connectors/ToolPanels.tsx', + 'components/V2Thread.tsx', + ]) { expect(source(file)).not.toMatch(/v2-activity__subtitle|v2-connectors__header p|

\{pod\.description\}<\/p>/); } }); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 25ec5d2fb6..0362bdb316 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2345,10 +2345,10 @@ describe('v2 layout invariants (CSS rule presence)', () => { it('nothing in v2.css hides the connector row\'s reason, at 390 or anywhere', () => { // Show is the default. This rule used to hide __detail for every state except // two enumerated ones, so the not-yet row that IS available - // (V2ConnectorTools.tsx:562) lost "install the GitHub App first" / + // (ToolPanels) lost "install the GitHub App first" / // "read, or read and write" and a stranger saw a "not granted" kicker with // nothing under it. The two reason strings are already render-asserted in - // V2ConnectorTools.test.tsx:280/:317 — this is the half jsdom cannot see. + // ConnectorsPage.tools.test.tsx — this is the half jsdom cannot see. // If one row ever needs the detail hidden on phones, add that selector to the // list below on purpose rather than re-introducing an exception chain. const NAMED_DETAIL_HIDE_CASES: string[] = []; @@ -2365,7 +2365,6 @@ describe('v2 layout invariants (CSS rule presence)', () => { // below vacuous: the sheet was read, it carries the class, and it carries the // not-yet row's details wrapper. expect(v2).toContain('.v2-connector-row__detail'); - expect(v2).toContain('.v2-connector-row--not-yet .v2-connector-row__details'); // Every hiding mechanism a declaration can use, not just the one that // caused this. `display:none` was the defect; `visibility:hidden` is the // same loss through a different property, and neither hides the rule from a @@ -2382,122 +2381,39 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(hidingRules).toEqual([]); }); - it('Signal connectors pin the row grid, aside, colour grammar, and phone collapse', () => { - // Direction A (2026-09-19): three tracks — the age moved into the kicker. - expect(ruleBody(v2, '.v2-connector-row')).toContain('grid-template-columns: 140px minmax(150px, 1fr) 120px'); - expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('grid-template-columns: 140px minmax(150px, 1fr)'); - // TASK-162 (2): the name track ends exactly where the details track begins, so - // a name that fills it touches the sentence. The gutter has to be on BOTH row - // grids — the article's and the selection button's inner one. - expect(ruleBody(v2, '.v2-connector-row')).toContain('column-gap: 12px'); - expect(ruleBody(v2, '.v2-root button.v2-connector-row__selection')).toContain('column-gap: 12px'); + it('Signal connectors pin the drawer row, aside, colour grammar, and phone collapse', () => { // TASK-162 (1): the ink hover must NOT claim a secondary action, or a hovered // gear is ink behind an ink glyph. Asserted on the SELECTOR rather than on a // computed style: jsdom resolves no :hover rules at all, so the cascade this - // bug lived in cannot be measured in-suite — only the shape that fixes it can. + // bug lived in cannot be measured in-suite, only the shape that fixes it can. const inkHoverChunks = [...v2.matchAll(/([^{}]+)\{([^{}]*)\}/g)] .filter(([, , body]) => body.includes('var(--v2-ink-hover)')) .flatMap(([, selector]) => selector.split(',')) .map((selector) => selector.trim()) .filter((selector) => selector.includes('.v2-connector-row__action:hover')); - // Split per SELECTOR, not per rule: both variants share one declaration block, - // so a rule-level check is satisfied by whichever selector kept the exclusion - // and reads green with the button — the gear that actually broke — still - // claiming secondary. Two separate assertions, one per line. + // Split per SELECTOR, not per rule: both variants share one declaration block. expect(inkHoverChunks).toHaveLength(2); expect(inkHoverChunks.find((selector) => selector.includes('button.v2-connector-row__action:hover'))).toContain(':not(:disabled)'); for (const selector of inkHoverChunks) { expect(selector).toContain(':not(.v2-connector-row__action--secondary)'); } - // TASK-162 (3): line 3 is the consequence as TEXT beside the mark (the mode - // word moved onto the mark itself) and the not-yet names stack above 760. - expect(ruleBody(v2, '.v2-connector-row__names')).toContain('flex-direction: column'); - // …and the mark SHARES the Tools glyph's rule rather than carrying a second - // rule of its own. The two rows had drifted 16 vs 14, gap 8 vs 6, secondary vs - // tertiary; a rule of its own can always drift again, so the guard counts the - // rules that mention the mark: the shared one and the ≤760 hide, nothing else. - const stripComments = (css: string): string => css.replace(/\/\*[\s\S]*?\*\//g, ''); - const markRuleChunks = [...stripComments(v2).matchAll(/([^{}]+)\{([^{}]*)\}/g)] - .filter(([, selector]) => selector.includes('.v2-connector-row__mark')); - expect(markRuleChunks).toHaveLength(2); - const sharedMarkRule = markRuleChunks.find(([, selector]) => selector.includes('.v2-tools__mode')); - expect(sharedMarkRule?.[2]).toContain('display: inline-flex'); - expect(sharedMarkRule?.[2]).toContain('vertical-align: -2px'); - expect(sharedMarkRule?.[2]).toContain('color: var(--v2-text-tertiary)'); - // Rule 3: the kicker is mono 11; rule 1 (as revised by the TASK-162 gate): the mode word is hidden until 760 and the mark's own 16px rule is GONE — the shared rule carries no dimensions, so the size is the glyph's `size` prop alone, pinned same-size against the Tools glyph in V2ConnectorsPage.test.tsx (TASK-177); rule 2: the gear is 32 (44 on the phone). + // Rule 3: the kicker is mono 11; the mode word is hidden until 760; the gear is 32. expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-connector-row__kicker')).toContain('font-size: 11px'); expect(ruleBody(v2, '.v2-connector-row__kicker-mode')).toContain('display: none'); - expect(v2).not.toContain('width: 16px; height: 16px; color: var(--v2-text-secondary)'); expect(ruleBody(v2, '.v2-root button.v2-connector-row__action--icon')).toContain('width: 32px'); - const phone760 = v2.slice(v2.indexOf('@media (max-width: 760px) {\n .v2-connectors {')); - expect(phone760).toContain('.v2-connector-row__kicker-mode { display: inline; }'); - expect(phone760).toContain('.v2-connector-row__names { display: block; }'); - expect(phone760).toContain('.v2-connector-row__name-sep { display: inline; }'); - expect(phone760).toContain('.v2-root button.v2-connector-row__action--icon { width: 44px; min-height: 44px;'); - // TASK-140: the aside's Revoke ✕ carries `--secondary` as well as `--icon`, - // and `--secondary` sets `min-height: 36px`. Because min-height beats height, - // a composed rule that pins `height: 32px` alone still measures 32×36 — the - // first cut of this fix did exactly that and the gate caught it (#1799, 1440). - // The assertion below is on `min-height` for that reason: this guard would - // otherwise have been green while the box was still 36 tall. + // TASK-140: the aside's Revoke carries `--secondary` as well as `--icon`, and + // `--secondary` sets `min-height: 36px`. min-height beats height, so the + // composed rule must pin min-height too or the box measures 32x36. const asideIcon = ruleBody(v2, '.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon'); expect(asideIcon).toContain('width: 32px'); expect(asideIcon).toContain('height: 32px'); expect(asideIcon).toContain('min-height: 32px'); - expect(phone760).toContain('.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon { width: 44px; height: 44px; min-height: 44px; }'); - expect(phone760).toContain('.v2-connector-row { min-height: 72px; }'); - // The gear shares row 1 with the name (the selection button is pinned to row 1 too), and it is declared AFTER the older act placement so the cascade keeps it. - expect(phone760).toContain('.v2-root button.v2-connector-row__selection { grid-row: 1; }'); - expect(phone760.indexOf('.v2-root button.v2-connector-row__action--icon { width: 44px')).toBeGreaterThan(phone760.indexOf('.v2-root a.v2-connector-row__action { grid-column: 2; grid-row: 1; z-index: 1; }')); - expect(ruleBody(v2, '.v2-connectors__content')).toContain('grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px)'); + expect(v2).toContain('.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon { width: 44px; height: 44px; min-height: 44px; }'); expect(ruleBody(v2, '.v2-connector-row__details')).toContain('padding-right: 8px'); - const connectors = ruleBody(v2, '.v2-connectors'); - // TASK-156, inverting #1547: a list card is as tall as its rows. The page - // scrolls; no card holds empty space. ux-lead measured 564px of card around - // 262px of rows at 1200, and a Tools card of 718px around one 86px row with - // the GitHub grant open. - expect(connectors).not.toContain('min-height'); - expect(connectors).not.toContain('max-width'); - const connectorsContent = ruleBody(v2, '.v2-connectors__content'); - expect(connectorsContent).toContain('align-items: start'); - expect(connectorsContent).not.toContain('flex: 1'); - expect(ruleBody(v2, '.v2-connectors__rows')).not.toContain('flex: 1'); - // sprint-review's #1869 note: ruleBody reads the FIRST line-start match, so a - // second `.v2-connectors { min-height: … }` written later in the sheet passes - // every assertion above while the browser paints the stretch again — equal - // specificity, later rule wins. Scan EVERY declaration block by selector, so - // position cannot hide one. Matching is on the last compound's class tokens - // (so `.v2-root .v2-connectors` counts, and `.v2-connectors__content` does - // not); a rule scoped some other way is still not covered. - const layoutBlocks = (className: string) => - Array.from(v2.matchAll(/([^{}]+)\{([^{}]*)\}/g)) - .filter(([, sels]) => - sels - .split(',') - .some((sel) => (sel.trim().split(/\s+/).pop() ?? '').split('.').includes(className)), - ) - .map(([, , body]) => body); - // Non-vacuous: each selector's scan must still find the rule it is about. - for (const cls of ['v2-connectors', 'v2-connectors__content', 'v2-connectors__rows']) { - expect(layoutBlocks(cls).length).toBeGreaterThan(0); - } - // A list card is as tall as its rows wherever the rule is written. - expect(layoutBlocks('v2-connectors').filter((b) => b.includes('min-height'))).toEqual([]); - expect(layoutBlocks('v2-connectors__content').filter((b) => b.includes('flex: 1'))).toEqual([]); - expect(layoutBlocks('v2-connectors__rows').filter((b) => b.includes('flex: 1'))).toEqual([]); - // TASK-677 tile grid: the channel rows tile at 150px minimum, the fold spans - // the grid, and the icon act is centred. jsdom cannot see the cascade, so the - // 760px overrides are guarded by presence here. - // ruleBody takes the first line-start match, which is the base rule, so scan - // every block for the tile declarations instead. - expect(layoutBlocks('v2-connectors__rows').some((b) => b.includes('repeat(auto-fill, minmax(150px, 1fr))'))).toBe(true); - expect(layoutBlocks('v2-connectors__inactive-fold').some((b) => b.includes('grid-column: 1 / -1'))).toBe(true); - expect(layoutBlocks('v2-connector-row__action--icon').some((b) => b.includes('justify-self: center'))).toBe(true); expect(ruleBody(v2, '.v2-page-head__meta')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('color: inherit'); - expect(ruleBody(v2, '.v2-connector-row--not-yet .v2-connector-row__glyph')).toContain('color: var(--v2-text-placeholder)'); expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('text-overflow'); expect(ruleBody(v2, '.v2-connector-row__detail')).not.toContain('nowrap'); // Line 1 and the gate's pod name wrap too; the 390 clips were the defect (64144). @@ -2510,40 +2426,82 @@ describe('v2 layout invariants (CSS rule presence)', () => { // The state dot is a mark: ink filled / hollow / grey, never cobalt, never a status colour. expect(ruleBody(v2, '.v2-connector-row__dot--live')).toContain('var(--v2-ink)'); expect(ruleBody(v2, '.v2-connector-row__dot--live')).not.toContain('var(--v2-accent)'); - expect(ruleBody(v2, '.v2-connector-row__dot--pending')).toContain('transparent'); - expect(ruleBody(v2, '.v2-connector-row__dot--idle')).toContain('var(--v2-border-soft)'); expect(ruleBody(v2, '.v2-connector-gate__mark')).toContain('width: 4px'); expect(ruleBody(v2, '.v2-connector-gate__mark')).toContain('var(--v2-ink)'); expect(ruleBody(v2, '.v2-connector-gate__switch')).toContain('accent-color: var(--v2-ink)'); expect(ruleBody(v2, '.v2-connector-gate__switch')).not.toContain('border-radius'); expect(ruleBody(v2, '.v2-connector-gate__tag')).toContain('var(--v2-font-mono)'); const connectorCss = v2.slice(v2.indexOf('/* ── Connectors'), v2.indexOf('/* Activity queue')); + expect(connectorCss.length).toBeGreaterThan(1000); expect(connectorCss).not.toContain('var(--v2-success)'); expect(connectorCss).not.toContain('var(--v2-warning)'); expect(connectorCss).not.toContain('var(--v2-danger)'); - // Tools (plan §6): the list reuses the connector row grid, and a trail line is muted mono — - // an outcome is a word, never a coloured one. + // Tools: a trail line is muted mono, an outcome is a word, never a coloured one. expect(ruleBody(v2, '.v2-tools__trail-line')).toContain('var(--v2-text-muted)'); expect(ruleBody(v2, '.v2-tools__trail-line')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-tools__count strong')).toContain('font-size: 22px'); expect(ruleBody(v2, '.v2-tools__count span')).toContain('font-size: 11px'); - expect(connectorCss).toMatch(/@media \(max-width: 1010px\) \{[\s\S]*?\.v2-connectors__content \{ grid-template-columns: minmax\(0, 1fr\); gap: 24px;/); - expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connector-row \{ grid-template-columns: minmax\(0, 1fr\) auto;/); - expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connectors__content \{ grid-template-columns: minmax\(0, 1fr\);/); - expect(connectorCss).toMatch(/@media \(max-width: 760px\) \{[\s\S]*?\.v2-connectors \{ gap: 24px; margin: -12px -18px 0;/); - // The Tools list opt-out is dead once nothing stretches. Matched as a regex on - // the Connectors section, not through ruleBody: with the rule gone ruleBody - // returns '' and would have passed this assertion without reading anything. expect(connectorCss).not.toMatch(/\.v2-tools \{[^}]*flex: none/); - expect(connectorCss).toMatch(/\.v2-root button\.v2-connector-row__selection \{ grid-column: 1 \/ -1;/); - expect(connectorCss).toMatch(/\.v2-root button\.v2-connector-row__selection \.v2-connector-row__details \{ grid-column: 1 \/ -1; grid-row: 2;/); - // The phone block used to hide __detail behind an exception list — whose - // presence was pinned HERE, which is why the shape survived until a third - // state fell outside it (eng lead 73726). The inverted shape (show, hide no - // row) is pinned by the test above this one; the enumerating rule is gone. + // The phone block keeps the row's reason rather than carrying an exception chain. expect(connectorCss).not.toContain('v2-connector-row--dead):not(.v2-connector-row--not-enabled) .v2-connector-row__detail'); }); + describe('connectors.css (tile grid, popover, drawer)', () => { + const cx = fs.readFileSync(path.join(__dirname, '../connectors/connectors.css'), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + // The text of every `@media (max-width: N)` block, brace-balanced, joined. + const mediaBlock = (px: number): string => { + const marker = `@media (max-width: ${px}px)`; + const blocks: string[] = []; + for (let from = cx.indexOf(marker); from > -1; from = cx.indexOf(marker, from + 1)) { + const open = cx.indexOf('{', from); + let depth = 0; + for (let i = open; i < cx.length; i += 1) { + if (cx[i] === '{') depth += 1; + if (cx[i] === '}') { depth -= 1; if (depth === 0) { blocks.push(cx.slice(open + 1, i)); break; } } + } + } + expect(blocks.length).toBeGreaterThan(0); + return blocks.join('\n'); + }; + const baseBody = (selector: string): string => { + const outside = cx.replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, ''); + const m = outside.match(new RegExp(`(?:^|\\})\\s*${selector.replace(/[.\\-]/g, '\\$&')}\\s*\\{([^}]*)\\}`)); + return m ? m[1] : ''; + }; + + it('the page body hands the full height to the tile grid with no padding', () => { + expect(cx).toMatch(/\.v2-feature--connectors > \.v2-feature__body \{[^}]*padding: 0;/); + }); + + it('the grid is six columns, three on a phone', () => { + expect(baseBody('.v2-cx-grid')).toContain('grid-template-columns: repeat(6, minmax(0, 1fr))'); + expect(mediaBlock(640)).toMatch(/\.v2-cx-grid \{[^}]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/); + }); + + it('the popover becomes a sheet above the tab bar at 760', () => { + const pop = mediaBlock(760).match(/\.v2-cx-pop \{([^}]*)\}/)?.[1] ?? ''; + expect(pop).toContain('position: fixed'); + expect(pop).toMatch(/bottom: calc\(84px/); + expect(pop).toContain('top: auto !important'); + // the popover's inline top and left must lose to the sheet, or it floats off-screen + expect(pop).toContain('left: 12px !important'); + expect(pop).toMatch(/max-height: calc\(100vh/); + }); + + it('colours come from the Signal tokens, so dark and auto themes follow the shell', () => { + expect(cx).toContain('--cx-key-a: var(--v2-key-a)'); + expect(cx).toContain('--cx-pop-shadow: var(--v2-shadow-pop)'); + expect(cx).not.toContain("data-theme='dark'"); + }); + + it('the drawer is fixed over the page at 760, clear of the tab bar', () => { + const drawer = mediaBlock(760).match(/\.v2-cx-drawer \{([^}]*)\}/)?.[1] ?? ''; + expect(drawer).toContain('position: fixed'); + expect(drawer).toMatch(/inset: 0 0 calc\(72px/); + expect(baseBody('.v2-cx-drawer')).toContain('position: absolute'); + }); + }); + it('connector refusal links keep the in-text link treatment in the parsed stylesheet', () => { const style = document.createElement('style'); style.textContent = v2; @@ -2830,7 +2788,7 @@ describe('v2 layout invariants (CSS rule presence)', () => { // And the surfaces the composer does not need stay OUT of it. for (const heavy of [ "'./components/V2PodBoard'", - "'./components/V2ConnectorsPage'", + "'./connectors/ConnectorsPage'", "'./components/V2ActivityPage'", "'./components/V2ArtifactsPage'", "'../components/ChatRoom'", @@ -3146,7 +3104,7 @@ describe('the landing hero demo (TASK-147)', () => { }); test('a row refusal takes a line of its own, under the row that raised it (Row C)', () => { - // A refused Slack authorize now renders inside its row (V2ConnectorsPage). + // A refused Slack authorize now renders inside its row (ConnectorsPage). // jsdom cannot see this: the row is a 140px / minmax(150px, 1fr) / 120px // grid, so without the span the message would be squeezed into the name or // act track instead of reading as one line — the TASK-029 failure mode. @@ -5001,17 +4959,6 @@ describe('BYO command boxes wrap at spaces', () => { }); }); -describe('connector inactive fold layout', () => { - const v2 = read('../v2.css'); - test('the toggle stays a full-width row with a visible keyboard focus state', () => { - const rule = ruleBody(v2, '.v2-root button.v2-connectors__inactive-toggle'); - expect(rule).toContain('width: 100%'); - expect(rule).toContain('min-height: 44px'); - expect(ruleBody(v2, '.v2-root button.v2-connectors__inactive-toggle:focus-visible')) - .toContain('box-shadow: inset 0 0 0 2px var(--v2-accent)'); - }); -}); - describe('GitHub gig card fits the chat column', () => { const css = read('../v2.css'); test('bounds card width, wraps long repository text, and releases the avatar inset on phones', () => { diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx deleted file mode 100644 index c8d848d67f..0000000000 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ /dev/null @@ -1,1256 +0,0 @@ -// Tools — the second list on the Connectors page (tools plan §6, option A). -// Same container grammar as the channels: dot, 20px glyph, -// display name, two-line middle, mono when, one action. It renders only what -// the server counts: a row per RoomGrant the person can see, a not-yet row per -// tool Installable the catalogue returns (#1670), the aside from the projected -// grant read, the trail from ToolCall rows the broker wrote. The Add form posts -// the mint exactly as the server takes it — never a brokerId. -// -// Direction A: categories are glyphs — a trail outcome and a -// grant's mode carry a mark with the word in `title`; the deciding act (Add, -// Grant, Manage on a row with one act) keeps its word. - -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; -import { useV2Api } from '../hooks/useV2Api'; -import { useRelativeNow } from '../hooks/useRelativeNow'; -import { localizeRelativeTime, localizeWindow, relativeParts } from '../utils/localizeRelativeTime'; -import { useAuth } from '../../context/AuthContext'; -import { V2Pod } from '../hooks/useV2Pods'; -import { PlatformGlyph } from '../icons/platforms'; -import { ActGlyph } from '../icons/glyphs'; -import { hostedMcpErrorNotice } from './hostedMcpErrorNotice'; -import { catalogDescription } from '../utils/connectorCatalogDescription'; -import { safeProviderRevokePage } from '../utils/safeProviderRevokePage'; -import V2InactiveFold from './V2InactiveFold'; - -export type GrantWriteMode = 'read' | 'write' | 'write-with-confirm'; - -export interface ToolGrant { - grantId: string; - installationId: string; - target: { kind: 'pod' | 'seat'; id: string }; - tools: string[]; - writeMode: GrantWriteMode; - budget: { calls?: number; windowMs?: number } | null; - effectiveAudience: string[]; - expiresAt: string; - revokedAt: string | null; - revokedBy: string | null; - parentGrantId: string | null; - rootGrantId: string | null; - createdAt: string; - grantedBy: string | null; -} - -/** `superseded` is a DERIVED read-only value the server reports for a parked - * request whose approval has since resolved: the call itself is the row beside - * it (ok / refused / failed), and this row is only the ask. It is never stored, - * so it is never what a write sends. */ -export type ToolOutcome = 'ok' | 'refused' | 'pending_approval' | 'failed' | 'superseded'; - -export interface ToolCallLine { - callId: string; - agentUserId: string; - tool: string; - outcome: ToolOutcome; - reason: string | null; - approvalId: string | null; - argsDigest: string; - at: string | null; - durationMs: number | null; -} - -export interface ToolTrail { - grantId: string; - calls: ToolCallLine[]; - counts: { total: number; ok: number; refused: number; pending_approval: number; failed: number }; -} - -/** A tool Installable as GET /api/installables lists it (list: 'tools', #1670). */ -export interface ToolCatalogEntry { - installableId: string; - list?: 'channels' | 'tools'; - label: string; - description: string; - connectNotice?: string; - /** - * Where the person chooses, at the vendor, what the connection can reach - * (GitHub: the App's install page, which picks the repositories). - */ - accessPage?: string; - /** - * The connection type this row's tools run through, and — on a hosted entry — - * the catalogue entry the start route takes. `entryId` is absent on - * `github-app`, whose connections are not per entry. - */ - connectionType?: 'github-app' | 'hosted-mcp'; - entryId?: string; - available: boolean; - unavailableReason?: string; - broker?: { id: string }; - tools: Array<{ name: string; description?: string; requiredWriteMode: GrantWriteMode; irreversible: boolean }>; - connections: Array<{ connectionId: string; owner: string; repo: string }>; -} - -interface HiddenToolConnection { - connectionId: string; - entryId: string; - label: string; - status: string; - revokePage: string | null; -} - -export interface HostedMcpManualRevokeNotice { - connectionId: string; - entryId: string; - provider: string; - revokePage: string; -} - -interface PodSeat { userId: string | null; displayName?: string; name: string; internal?: boolean } - -interface Props { - pods: V2Pod[]; - manualRevokeNotice?: HostedMcpManualRevokeNotice | null; -} - -interface DraftGrant { - entry: ToolCatalogEntry; - podId: string; - connectionId: string; - writeMode: GrantWriteMode; - audience: string[]; - expiryDays: 7 | 30 | 90; - /** Change access: the grant this one replaces (revoked after the mint). */ - replaces: string | null; -} - -interface GithubAppSetup { - installationId: string; - owner: string; - repo: string; -} - -interface GithubAppIntegrationResponse { - integration?: { - createdBy?: string | { _id?: string } | null; - }; -} - -const USED_RECENTLY_MS = 10 * 60 * 1000; -const MODE_RANK: Record = { read: 0, 'write-with-confirm': 1, write: 2 }; -// The two option sets the aside renders as buttons. They live here, not inline in the -// JSX, because eslint's i18next/no-literal-string (jsx-only) reads an array literal in -// a JSX expression as copy — and these are ids, not words (TASK-164). -const WRITE_MODES: GrantWriteMode[] = ['read', 'write-with-confirm', 'write']; - -/** - * The age as a unit-suffixed number ("23d", "5m", "just now"). Rounds through - * relativeParts, the same rule as the "revoked … ago" line, so one timestamp - * pair never reads "1d" in one place and "2d" in the other (TASK-649). - */ -export const shortAge = (date: string | null | undefined, now: number, t: (key: string, options?: Record) => string): string => { - const parts = relativeParts(date, { includeFuture: false, now }); - if (parts.kind !== 'rel') return t('time.age.justNow', { defaultValue: 'just now' }); - if (parts.unit === 'minute') return t('time.age.minutes', { defaultValue: '{{n}}m', n: parts.count }); - if (parts.unit === 'hour') return t('time.age.hours', { defaultValue: '{{n}}h', n: parts.count }); - return t('time.age.days', { defaultValue: '{{n}}d', n: parts.count }); -}; - -// Both surfaces used to carry their own copy of this grammar; the locale family -// lives in one place now (TASK-164). Re-exported so the module's surface is -// unchanged for anything importing it from here. -export { localizeRelativeTime, relativeTime } from '../utils/localizeRelativeTime'; - -const isExpired = (grant: ToolGrant, now = Date.now()): boolean => new Date(grant.expiresAt).getTime() <= now; -// Callers that decide what a render SHOWS pass that render's `now`; the read -// path (`load`) leaves the default, because there the question is what is -// live at the moment of the read. -const isDead = (grant: ToolGrant, now = Date.now()): boolean => Boolean(grant.revokedAt) || isExpired(grant, now); - -// A catalogue entry is an Installable; a grant carries tool names and an -// `installationId` that names an install, not a catalogue id. The names are the -// only key both sides share, so an entry is the one whose tools the grant names. -const entryCovers = (entry: ToolCatalogEntry, grant: ToolGrant): boolean => ( - (grant.tools || []).some((name) => (entry.tools || []).some((tool) => tool.name === name)) -); -// A grant that names no tool of this entry grants nothing on it — that is what -// the enforcement path does (roomGrantService refuses any tool the grant does -// not list), so the page must not read such an entry as granted. -const entryIsGranted = (entry: ToolCatalogEntry, grants: ToolGrant[], now: number): boolean => ( - grants.some((grant) => !isDead(grant, now) && entryCovers(entry, grant)) -); - -const G: React.FC<{ children: React.ReactNode }> = ({ children }) => ( - -); -/** Outcome marks: a category, so a glyph; the word stays in the log line and the title. - * - * The `superseded` mark is a shield with a SHORT BAR, not the shield-and-check it - * first shipped with: the resolution it stands for may be an - * approval, a decline or an expiry, and the row beside it carries the verdict — - * a check read as "approved" next to a sibling that says `refused`. The bar says - * settled without saying how. */ -const OutcomeGlyph: React.FC<{ outcome: ToolOutcome }> = ({ outcome }) => { - if (outcome === 'ok') return ; - if (outcome === 'refused') return ; - if (outcome === 'pending_approval') return ; - if (outcome === 'superseded') return ; - return ; -}; -/** Mode marks: eye for read, pen for write, shield for write that asks first. */ -const ModeGlyph: React.FC<{ mode: GrantWriteMode }> = ({ mode }) => { - if (mode === 'read') return ; - if (mode === 'write') return ; - return ; -}; - -const PER_ENTRY_REVOKE_COPY = new Set(['supabase', 'linear', 'sentry']); - -const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) => { - const { t, i18n } = useTranslation(); - const api = useV2Api(); - // Revoke and Change access are the granter's: the route 403s anyone else. - const { currentUser } = useAuth(); - const viewerId = currentUser?._id ? String(currentUser._id) : ''; - const isGranter = (grant: ToolGrant): boolean => Boolean(viewerId) && grant.grantedBy === viewerId; - const [rawGrants, setGrants] = useState(null); - const [rawCatalog, setCatalog] = useState([]); - // A connection the server confirmed removed stays removed on this page, as a stale catalogue read could bring it back. - const [disconnectedHere, setDisconnectedHere] = useState([]); - const [hiddenConnections, setHiddenConnections] = useState([]); - const [seats, setSeats] = useState>({}); - const [lastUse, setLastUse] = useState>({}); - const [trail, setTrail] = useState(null); - const [trailError, setTrailError] = useState(false); - const [selectedId, setSelectedId] = useState(null); - const [draft, setDraft] = useState(null); - const [githubAppSetup, setGithubAppSetup] = useState(null); - // Change access minted the new grant but the revoke of the old one failed: only the revoke is retried. - const [staleAfterChange, setStaleAfterChange] = useState<{ oldGrantId: string; newGrantId: string } | null>(null); - const [confirmRevoke, setConfirmRevoke] = useState(null); - const [confirmDisconnect, setConfirmDisconnect] = useState(null); - // A revoke is irreversible, so once the server has said yes the grant stays - // revoked on this page whatever a later read returns. Without this, the card - // kept saying "granted" when the reload came back stale or failed. - const [revokedHere, setRevokedHere] = useState>({}); - const [confirmHiddenRemoval, setConfirmHiddenRemoval] = useState(null); - const [removedHiddenNotice, setRemovedHiddenNotice] = useState<{ label: string; revokePage: string | null } | null>(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [joinPolicyFixPodId, setJoinPolicyFixPodId] = useState(null); - const [policyFixedPodId, setPolicyFixedPodId] = useState(null); - const [query, setQuery] = useState(''); - const catalog = useMemo(() => (disconnectedHere.length === 0 ? rawCatalog : rawCatalog.map((entry) => ({ - ...entry, - connections: entry.connections.filter((connection) => !disconnectedHere.includes(connection.connectionId)), - }))), [rawCatalog, disconnectedHere]); - const grants = useMemo(() => (rawGrants === null ? null : rawGrants.map((grant) => ( - revokedHere[grant.grantId] && !grant.revokedAt - ? { ...grant, revokedAt: revokedHere[grant.grantId], revokedBy: grant.revokedBy ?? (viewerId || null) } - : grant - ))), [rawGrants, revokedHere, viewerId]); - // Grants and trail rows carry relative ages; they must advance while the - // page sits open and be re-read when the tab comes back (TASK-131). - const now = useRelativeNow(); - - const isAdmin = currentUser?.role === 'admin'; - - const podIds = useMemo(() => pods.map((pod) => String(pod._id)), [pods]); - - const load = useCallback(async () => { - const catalogRes = await api.get<{ installables?: ToolCatalogEntry[]; hiddenToolConnections?: HiddenToolConnection[] }>('/api/installables').catch(() => null); - setCatalog((catalogRes?.installables || []).filter((entry) => entry.list === 'tools')); - setHiddenConnections(catalogRes?.hiddenToolConnections || []); - const [grantRes, seatResults] = await Promise.all([ - api.get<{ grants: ToolGrant[] }>('/api/grants', { params: { connectionOwner: 'me' } }).catch(() => null), - Promise.all(podIds.map(async (podId) => { - const res = await api.get<{ agents?: PodSeat[] }>(`/api/registry/pods/${podId}/agents`).catch(() => null); - return [podId, (res?.agents ?? []).filter((seat) => !seat.internal)] as const; - })), - ]); - const ownedGrants = grantRes?.grants || []; - // A failed read must not blank a list the person is looking at. - if (grantRes) setGrants(ownedGrants); else setGrants((current) => current ?? []); - setSeats(Object.fromEntries(seatResults)); - // "used in the last 10 min" reads the newest trail row's `at` (§6). - const uses = await Promise.all(ownedGrants.filter((grant) => !isDead(grant)).map(async (grant) => { - const res = await api.get(`/api/grants/${grant.grantId}/calls`, { params: { limit: 1 } }).catch(() => null); - return [grant.grantId, res?.calls?.[0]?.at ?? null] as const; - })); - setLastUse(Object.fromEntries(uses)); - }, [api, podIds]); - - useEffect(() => { void load(); }, [load]); - - useEffect(() => { - const onVisibility = () => { if (document.visibilityState === 'visible') void load(); }; - document.addEventListener('visibilitychange', onVisibility); - return () => document.removeEventListener('visibilitychange', onVisibility); - }, [load]); - - useEffect(() => { - if (!selectedId) { setTrail(null); return undefined; } - let cancelled = false; - setTrail(null); - setTrailError(false); - api.get(`/api/grants/${selectedId}/calls`) - .then((res) => { if (!cancelled) setTrail(res); }) - .catch(() => { if (!cancelled) setTrailError(true); }); - return () => { cancelled = true; }; - }, [api, selectedId]); - - // The entry a grant belongs to is the one whose namespaced tools it carries. - // If the catalogue no longer has that entry, keep the row visibly unknown; - // assigning it to GitHub would misstate who owns the grant and its policy. - const entryFor = (grant: ToolGrant): ToolCatalogEntry | null => ( - catalog.find((entry) => entryCovers(entry, grant)) || null - ); - const noticeMatchesEntry = (entry: ToolCatalogEntry | null): boolean => Boolean( - manualRevokeNotice - && entry?.entryId === manualRevokeNotice.entryId - && entry.connections.some((connection) => connection.connectionId === manualRevokeNotice.connectionId), - ); - const renderManualRevokeNotice = (entry: ToolCatalogEntry | null) => { - if (!manualRevokeNotice || !noticeMatchesEntry(entry)) return null; - // Per-entry copy names only what that vendor's page lists; entries without it fall back to the generic line. - const context = PER_ENTRY_REVOKE_COPY.has(manualRevokeNotice.entryId) ? manualRevokeNotice.entryId : undefined; - return ( -

- {t('connectors.hostedMcpManualRevoke', { - defaultValue: 'Your previous {{provider}} account may still have Commonly connected. Switch to it on {{provider}}, then remove Commonly there.', - context, - provider: manualRevokeNotice.provider, - })}{' '} - - {t('connectors.hostedMcpReviewAuthorization', { - defaultValue: "Open {{provider}}'s connected apps", - context, - provider: manualRevokeNotice.provider, - })} - -

- ); - }; - const toolLabel = (grant: ToolGrant): string => entryFor(grant)?.label - || t('tools.unknownConnector', { defaultValue: 'Unknown connector' }); - - const podName = (podId: string): string => pods.find((pod) => String(pod._id) === podId)?.name || t('tools.aPod', { defaultValue: 'a pod' }); - // A bot member takes its curated displayName, then a non-default instanceId, then its - // username. This client does not screen leaked runtime labels the way the server's - // resolveAgentDisplayLabel does, so a bot whose displayName is a runtime name shows it. - const memberName = (userId: string | null): string | null => { - if (!userId) return null; - for (const pod of pods) { - for (const member of pod.members || []) { - if (typeof member !== 'object' || !member || String(member._id) !== userId) continue; - const instanceId = member.botMetadata?.instanceId; - const botLabel = member.botMetadata?.displayName?.trim() - || (instanceId && instanceId !== 'default' ? instanceId : ''); - if (botLabel) return botLabel; - if (member.username) return member.username; - } - } - return null; - }; - const seatLabel = (podId: string | null, userId: string): string => { - const pool = podId ? (seats[podId] || []) : Object.values(seats).flat(); - const seat = pool.find((row) => row.userId === userId) || Object.values(seats).flat().find((row) => row.userId === userId); - if (seat) return seat.displayName || seat.name; - // `seats` is the agent list, and a pod grant's audience is a snapshot of the - // room's members — so a human in it resolved to nothing and the row read - // "an agent, an agent, ". Name the member instead of asserting an - // identity the server never claimed (TASK-050). - return memberName(userId) || t('tools.aSeat', { defaultValue: 'an agent' }); - }; - const grantPodId = (grant: ToolGrant): string | null => { - if (grant.target.kind === 'pod') return grant.target.id; - const entry = Object.entries(seats).find(([, rows]) => rows.some((row) => row.userId === grant.target.id)); - return entry ? entry[0] : null; - }; - // zh joins a list with 、 and en with ', ', so the separator is copy and not a - // literal at each join site (TASK-164). - const joinList = (items: string[]): string => items.join(t('tools.listSeparator', { defaultValue: ', ' })); - const audienceLabels = (grant: ToolGrant): string => { - const podId = grantPodId(grant); - const labels = grant.effectiveAudience.map((id) => seatLabel(podId, id)); - if (labels.length === 0) return t('tools.nobody', { defaultValue: 'no agent' }); - return joinList(labels); - }; - const irreversibleTools = (entry: ToolCatalogEntry | null, tools: string[]): string[] => (entry?.tools || []) - .filter((tool) => tool.irreversible && tools.includes(tool.name)).map((tool) => tool.name); - const asksFirst = (grant: ToolGrant): string => { - if (grant.writeMode === 'read') return t('tools.asksNothing', { defaultValue: 'nothing asks first' }); - if (grant.writeMode === 'write-with-confirm') return t('tools.asksEveryWrite', { defaultValue: 'every write asks first' }); - // Under `write` the floor is the tool's own irreversible flag (piece 2b): the list is the catalogue's. - const entry = entryFor(grant); - if (!entry) return t('tools.unknownWritePolicy', { defaultValue: 'write policy unavailable' }); - const list = irreversibleTools(entry, grant.tools); - return list.length - ? t('tools.asksList', { defaultValue: '{{tools}} ask first', tools: joinList(list) }) - : t('tools.asksNothing', { defaultValue: 'nothing asks first' }); - }; - const outcomeLabel = (outcome: ToolOutcome): string => ({ - ok: t('tools.outcomeOk', { defaultValue: 'ok' }), - refused: t('tools.outcomeRefused', { defaultValue: 'refused' }), - pending_approval: t('tools.awaiting', { defaultValue: 'awaiting a person' }), - superseded: t('tools.outcomeSuperseded', { defaultValue: 'answered' }), - failed: t('tools.outcomeFailed', { defaultValue: 'failed' }), - })[outcome] || outcome; - const modeLabel = (mode: GrantWriteMode): string => ({ - read: t('tools.modeRead', { defaultValue: 'read' }), - 'write-with-confirm': t('tools.modeWriteConfirm', { defaultValue: 'read and write, ask first' }), - write: t('tools.modeWrite', { defaultValue: 'read and write' }), - })[mode]; - - const entryGrantModes = (entry: ToolCatalogEntry): GrantWriteMode[] => { - if (entry.connectionType !== 'hosted-mcp') return WRITE_MODES; - return entry.tools.some((tool) => tool.requiredWriteMode !== 'read') - ? ['read', 'write-with-confirm'] - : ['read']; - }; - - const entryModeCopy = (entry: ToolCatalogEntry): string => { - const modes = entryGrantModes(entry); - return modes.length === 1 - ? modeLabel(modes[0]) - : t('tools.readOrWrite', { defaultValue: 'read, or read and write' }); - }; - - const usedRecently = (grant: ToolGrant): boolean => { - const at = lastUse[grant.grantId]; - return Boolean(at) && Date.now() - new Date(at as string).getTime() < USED_RECENTLY_MS; - }; - - const q = query.trim().toLowerCase(); - const rows = useMemo(() => (grants || []).filter((grant) => { - if (!q) return true; - return toolLabel(grant).toLowerCase().includes(q) || grant.tools.some((tool) => tool.toLowerCase().includes(q)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }), [grants, q, catalog]); - // Catalogue entries without a live grant are offers. `now` is a dep so an - // expired grant becomes inactive and its catalogue entry becomes an offer. - const notYet = useMemo(() => catalog.filter((entry) => ( - !entryIsGranted(entry, grants || [], now) - && (!q || entry.label.toLowerCase().includes(q) || entry.tools.some((tool) => tool.name.toLowerCase().includes(q))) - )), [catalog, grants, now, q]); - const liveRows = rows.filter((grant) => !isDead(grant, now)); - const inactiveRows = rows.filter((grant) => isDead(grant, now)); - const matchedHiddenConnections = hiddenConnections.filter((connection) => !q || connection.label.toLowerCase().includes(q)); - const inactiveCount = inactiveRows.length + matchedHiddenConnections.length; - // Grants, not entries: a person may hold two live grants on one tool. - const grantedCount = (grants || []).filter((grant) => !isDead(grant, now)).length; - // Entries, from the same predicate the Not yet list filters on, so the header - // cannot claim fewer than the list shows. Deliberately not `notYet.length`: - // that one honours the search box too, and the header must not move as you type. - const moreCount = catalog.filter((entry) => !entryIsGranted(entry, grants || [], now)).length; - - const selected = selectedId ? (grants || []).find((grant) => grant.grantId === selectedId) || null : null; - - const openDraft = (entry: ToolCatalogEntry, from?: ToolGrant) => { - const podId = from ? (grantPodId(from) || podIds[0] || '') : (podIds[0] || ''); - const modes = entryGrantModes(entry); - const agentIds = (seats[podId] || []).map((seat) => seat.userId).filter((id): id is string => Boolean(id)); - setDraft({ - entry, - podId, - connectionId: entry.connections[0]?.connectionId || '', - writeMode: from && modes.includes(from.writeMode) ? from.writeMode : modes[0] || 'read', - audience: from ? agentIds.filter((id) => from.effectiveAudience.includes(id)) : agentIds, - expiryDays: 7, - replaces: from && !isDead(from) ? from.grantId : null, - }); - setSelectedId(null); - setConfirmRevoke(null); - setError(null); - setJoinPolicyFixPodId(null); - setPolicyFixedPodId(null); - }; - const openGithubAppSetup = () => { - setGithubAppSetup({ installationId: '', owner: '', repo: '' }); - setDraft(null); - setSelectedId(null); - setConfirmRevoke(null); - setError(null); - }; - // A background catalogue read can fail or stop offering this connection. - // Keep the open draft (and any server refusal) visible in either case. The - // mint still checks ownership, connection state, tools, audience and pod policy. - const draftEntry = draft - ? catalog.find((entry) => entry.installableId === draft.entry.installableId) || draft.entry - : null; - const draftTools = (entry: ToolCatalogEntry | null, mode: GrantWriteMode): string[] => (entry?.tools || []) - .filter((tool) => MODE_RANK[tool.requiredWriteMode] <= MODE_RANK[mode]).map((tool) => tool.name); - - const selectedAgentIds = (draft ? seats[draft.podId] || [] : []) - .map((seat) => seat.userId) - .filter((id): id is string => typeof id === 'string' && Boolean(id) && draft?.audience.includes(id) === true); - - const submitDraft = async () => { - if (!draft || !draftEntry || selectedAgentIds.length === 0) return; - setBusy(true); - setError(null); - setJoinPolicyFixPodId(null); - setPolicyFixedPodId(null); - try { - const expiresAt = new Date(Date.now() + draft.expiryDays * 86_400_000).toISOString(); - // The mint's body, as routes/grants.ts takes it: the server names the broker - // and takes the installation from the Connection (#1677) — neither is the caller's. - const minted = await api.post<{ grantId?: string }>('/api/grants', { - connectionId: draft.connectionId, - target: { kind: 'pod', id: draft.podId }, - tools: draftTools(draftEntry, draft.writeMode), - writeMode: draft.writeMode, - audience: selectedAgentIds, - expiresAt, - }); - // A resolved request alone is not a mint receipt (for example, an HTML - // fallback or empty response). Keep the form and the old grant intact. - if (typeof minted?.grantId !== 'string' || !minted.grantId.trim()) { - setError(t('tools.grantError', { defaultValue: 'Could not grant it.' })); - return; - } - // Change access: a change is a new grant and a revoke of the old one, because `tools` is never - // widened in place. The mint is kept whatever happens next; a failed revoke is retried alone - // (0368992e), never by minting a third grant. - if (draft.replaces) { - const oldGrantId = draft.replaces; - const newGrantId = String(minted?.grantId || ''); - try { - await api.post(`/api/grants/${oldGrantId}/revoke`); - markRevoked(oldGrantId); - } catch { - setStaleAfterChange({ oldGrantId, newGrantId }); - setDraft(null); - setSelectedId(oldGrantId); - await load(); - return; - } - } - setDraft(null); - await load(); - } catch (err) { - const code = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data; - setError(code?.message || code?.error || t('tools.grantError', { defaultValue: 'Could not grant it.' })); - const creator = pods.find((pod) => pod._id === draft.podId)?.createdBy as { _id?: string } | string | undefined; - const owner = typeof creator === 'string' ? creator : creator?._id; - if (code?.error === 'outsider_grant_pod_joinable' && owner === viewerId) { - setJoinPolicyFixPodId(draft.podId); - } - } finally { - setBusy(false); - } - }; - - const fixJoinPolicy = async () => { - if (!draft || joinPolicyFixPodId !== draft.podId || busy) return; - setBusy(true); - try { - await api.patch(`/api/pods/${encodeURIComponent(draft.podId)}/join-policy`, { joinPolicy: 'invite-only' }); - setJoinPolicyFixPodId(null); - setPolicyFixedPodId(draft.podId); - setError(null); - } catch (requestError) { - const data = (requestError as { response?: { data?: { message?: string; error?: string } } })?.response?.data; - setError(data?.message || data?.error || t('tools.joinPolicyError', { defaultValue: 'Could not make this pod invite-only.' })); - } finally { - setBusy(false); - } - }; - - /** - * Connect a hosted-MCP entry (scope §4): the start route answers with the - * vendor's authorize URL and sets the flow's browser nonce as an httpOnly - * cookie on the SAME response. - * - * `withCredentials` is what keeps that cookie: the API is a different origin - * from the app, so without it the browser drops the `Set-Cookie` and every - * callback refuses `invalid_state` — a person consents at the vendor and comes - * back to a row that is still not connected. A backend test sets the cookie - * itself and cannot witness this, which is why it is asserted here. - */ - const connectHostedMcp = async (entry: ToolCatalogEntry) => { - if (busy || !entry.entryId) return; - const authorizationWindow = window.open('', '_blank'); - setBusy(true); - setError(null); - try { - const result = await api.post<{ authorizeUrl?: string }>( - `/api/integrations/connect/hosted-mcp/${encodeURIComponent(entry.entryId)}/start`, - {}, - { withCredentials: true }, - ); - if (!result.authorizeUrl) throw new Error('authorization URL was missing'); - if (authorizationWindow) { - authorizationWindow.opener = null; - authorizationWindow.location.assign(result.authorizeUrl); - } else { - window.location.assign(result.authorizeUrl); - } - } catch (err) { - authorizationWindow?.close(); - const data = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data; - setError(hostedMcpErrorNotice(t, data?.error)); - } finally { - setBusy(false); - } - }; - - const submitGithubAppSetup = async () => { - if (!githubAppSetup || !isAdmin) return; - const installationId = githubAppSetup.installationId.trim(); - const owner = githubAppSetup.owner.trim(); - const repo = githubAppSetup.repo.trim(); - if (!installationId || !owner || !repo) { - setError(t('tools.githubAppRequired', { defaultValue: 'Enter the installation ID, owner, and repository.' })); - return; - } - setBusy(true); - setError(null); - try { - const response = await api.post('/api/integrations/github-app', { installationId, owner, repo }); - const createdBy = response?.integration?.createdBy; - const createdById = typeof createdBy === 'string' - ? createdBy - : createdBy && typeof createdBy === 'object' && createdBy._id - ? String(createdBy._id) - : ''; - if (createdById && viewerId && createdById !== viewerId) { - setError(t('tools.githubAppOwnedByOther', { defaultValue: 'This GitHub App is already installed by another administrator.' })); - return; - } - setGithubAppSetup(null); - await load(); - } catch (err) { - const code = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data; - setError(code?.message || code?.error || t('tools.githubAppError', { defaultValue: 'Could not install the GitHub App.' })); - } finally { - setBusy(false); - } - }; - - const markRevoked = (grantId: string) => { - setRevokedHere((current) => ({ ...current, [grantId]: new Date().toISOString() })); - }; - - const retryStaleRevoke = async () => { - if (!staleAfterChange) return; - setBusy(true); - setError(null); - try { - await api.post(`/api/grants/${staleAfterChange.oldGrantId}/revoke`); - markRevoked(staleAfterChange.oldGrantId); - setStaleAfterChange(null); - await load(); - } catch { - setError(t('tools.revokeError', { defaultValue: 'Could not revoke the grant.' })); - } finally { - setBusy(false); - } - }; - - const revoke = async (grant: ToolGrant) => { - setBusy(true); - setError(null); - try { - await api.post(`/api/grants/${grant.grantId}/revoke`); - markRevoked(grant.grantId); - setConfirmRevoke(null); - await load(); - } catch { - setError(t('tools.revokeError', { defaultValue: 'Could not revoke the grant.' })); - } finally { - setBusy(false); - } - }; - - const removeHiddenConnection = async (connection: HiddenToolConnection) => { - setBusy(true); - setError(null); - try { - await api.del(`/api/integrations/${encodeURIComponent(connection.connectionId)}`); - setConfirmHiddenRemoval(null); - if (connection.revokePage) setRemovedHiddenNotice({ label: connection.label, revokePage: connection.revokePage }); - await load(); - } catch { - setError(t('tools.removeConnectionError', { defaultValue: 'Could not remove the connection.' })); - } finally { - setBusy(false); - } - }; - - // A live connection of an offered hosted connector. The removal verb is the - // same one the not-offered row uses; the server revokes the connection's grants - // as part of it, and answers `revokeAt` when the provider's own page still - // needs a visit. - const disconnect = async (entry: ToolCatalogEntry, connectionId: string) => { - setBusy(true); - setError(null); - try { - const res = await api.del<{ revokeAt?: string }>(`/api/integrations/${encodeURIComponent(connectionId)}`); - setConfirmDisconnect(null); - setDisconnectedHere((current) => [...current, connectionId]); - // The removal revokes every grant on the connection, so the page says so - // now rather than waiting on a re-read that may fail. - const ended = (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant)); - setRevokedHere((current) => ({ ...current, ...Object.fromEntries(ended.map((grant) => [grant.grantId, new Date().toISOString()])) })); - setRemovedHiddenNotice({ label: entry.label, revokePage: safeProviderRevokePage(res?.revokeAt) ? res.revokeAt : null }); - await load(); - } catch (err) { - // The route answers 502 with one of these codes when the grants are already - // revoked and the row is disconnected, and only the provider's own revoke - // failed. A bare 502 (ingress, a pod restarting) reached nothing, so it takes - // the plain error below and marks nothing revoked. - const response = (err as { response?: { status?: number; data?: { code?: string; revokeAt?: string } } })?.response; - const partial = response?.status === 502 - && (response.data?.code === 'provider_revoke_failed' || response.data?.code === 'provider_revoked_removal_incomplete'); - if (partial) { - const ended = (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant)); - setRevokedHere((current) => ({ ...current, ...Object.fromEntries(ended.map((grant) => [grant.grantId, new Date().toISOString()])) })); - setConfirmDisconnect(null); - setDisconnectedHere((current) => [...current, connectionId]); - await load(); - if (safeProviderRevokePage(response?.data?.revokeAt)) { - setRemovedHiddenNotice({ label: entry.label, revokePage: response?.data?.revokeAt as string }); - } else { - setError(t('tools.disconnectPartial', { defaultValue: 'Disconnected {{label}} here, but {{label}} did not confirm. Remove Commonly in its connected apps.', label: entry.label })); - } - } else { - setError(t('tools.disconnectError', { defaultValue: 'Could not disconnect {{label}}.', label: entry.label })); - } - } finally { - setBusy(false); - } - }; - - // Hosted connections only: a GitHub App connection is the instance's, not the viewer's to drop. - const renderDisconnect = (entry: ToolCatalogEntry | null, variant: 'row' | 'aside') => { - // One connection only: a grant does not say which connection it was minted on, - // so with several the button could drop the wrong one. - const connection = entry && entry.connectionType === 'hosted-mcp' && entry.connections.length === 1 ? entry.connections[0] : null; - if (!entry || !connection) return null; - const cls = variant === 'row' - ? 'v2-connector-row__action v2-connector-row__action--secondary' - : 'v2-connector-aside__secondary'; - if (confirmDisconnect === connection.connectionId) { - return ( - <> - - - - ); - } - return ( - - ); - }; - - const renderHiddenConnection = (connection: HiddenToolConnection) => ( -
- - - - {t('tools.existingConnection', { defaultValue: 'existing connection' })} - {t('tools.noLongerOffered', { defaultValue: 'This connector is no longer offered here.' })} - {connection.revokePage && {t('tools.reviewProviderAccess', { defaultValue: 'Review access at {{label}}', label: connection.label })}} - - {confirmHiddenRemoval === connection.connectionId ? ( - <> - - - - ) : ( - - )} -
- ); - - const renderRow = (grant: ToolGrant) => { - const dead = isDead(grant, now); - const podId = grantPodId(grant); - const granter = memberName(grant.grantedBy); - const isSelected = selectedId === grant.grantId; - const entry = entryFor(grant); - const label = toolLabel(grant); - const noticeGrantId = noticeMatchesEntry(entry) - ? (grants || []).find((candidate) => ( - !isDead(candidate, now) && entryFor(candidate)?.installableId === entry?.installableId - ))?.grantId - : null; - // What the grant was given TO, which is a seat when the target is a seat. It - // is not the row's location: the kicker and the accessible name both name the - // pod the row lives in, so a seat grant under Ops has a sentence reading - // `granted to Reed` in a row announced as `View GitHub in Ops`. Sharing one - // label between the two put the seat's name where the location belongs - // (TASK-179, ux-lead's gate at 07873c19) — two nouns, so two expressions. - const targetLabel = grant.target.kind === 'pod' ? podName(grant.target.id) : seatLabel(podId, grant.target.id); - // Direction A rule 3: `pod · verb age` — the verb from a key, the age from the timestamp. - const when = t('tools.grantedAge', { defaultValue: 'granted {{age}}', age: shortAge(grant.createdAt, now, t) }); - const kicker = `${podId ? podName(podId) : seatLabel(null, grant.target.id)} · ${when}`; - const revokedBy = grant.revokedBy ? memberName(grant.revokedBy) : null; - const line2 = dead - ? (grant.revokedAt - ? (revokedBy - ? t('tools.revokedByLine', { defaultValue: 'revoked by {{member}} {{rel}}', member: revokedBy, rel: localizeRelativeTime(grant.revokedAt, t, { now, pastEvent: true }) }) - : t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: localizeRelativeTime(grant.revokedAt, t, { now, pastEvent: true }) })) - : t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: localizeRelativeTime(grant.expiresAt, t, { now }) })) - // Direction A rule 1: the write mode is the glyph beside this line; its words ride the 390 kicker. - // One interpolated key, not `{{agents}}` + a separate 'may use it': a language - // that orders the clause differently needs the whole sentence (TASK-164). The - // rendered English is unchanged. - // An empty audience is its own sentence, not the interpolated one with an empty - // subject: zh writes 「没有智能体可以使用」 with no space, which `{{agents}} 可以使用` - // cannot produce (TASK-179). - : (grant.effectiveAudience.length === 0 - ? t('tools.nobodyMayUse', { defaultValue: 'nobody may use it' }) - : t('tools.mayUse', { defaultValue: '{{agents}} may use it', agents: audienceLabels(grant) })); - return ( -
- - {dead && entry && isGranter(grant) ? ( - // Grant again is the granter's too: the mint 403s anyone but the Connection's owner. - - ) : ( - // Direction A rule 2: Manage is housekeeping beside the row's word, so it is the gear. - - )} - {noticeGrantId === grant.grantId && renderManualRevokeNotice(entry)} -
- ); - }; - - // Where the person who connected a hosted entry picks what it reaches (for - // GitHub, the App's install page). Shown on the offer and on the details of - // their live grant, since a grant takes the entry off the offer list. - const renderAccessLink = (entry: ToolCatalogEntry) => ( - - {t('tools.chooseAccess', { defaultValue: 'Choose what {{label}} can reach', label: entry.label })} - - ); - - const renderNotYet = (entry: ToolCatalogEntry) => { - const canAdd = entry.available && entry.connections.length > 0 && podIds.length > 0; - const canInstallGithubApp = isAdmin && entry.installableId === 'github' && entry.available && entry.connections.length === 0; - // A hosted entry is connected by the person who will grant it (§1: member - // mode), so the action belongs on the row and needs no admin. - const isHosted = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId); - const canConnectHosted = Boolean(isHosted) && entry.available && entry.connections.length === 0; - return ( -
- - - - - {t('tools.notGranted', { defaultValue: 'not granted' })} - {entry.entryId === 'atlassian' && · {t('tools.readInTesting', { defaultValue: 'read in testing' })}} - - {catalogDescription(i18n, entry.installableId, entry.description)} - - {entry.available && (entry.connections.length === 0 - ? (isHosted - ? t('tools.connectOwn', { defaultValue: 'connect your own {{label}} account', label: entry.label }) - : t('tools.noConnection', { defaultValue: 'install the GitHub App first · an admin does this once' })) - : entryModeCopy(entry))} - - {canConnectHosted && entry.connectNotice && ( - {entry.connectNotice} - )} - {isHosted && entry.available && entry.accessPage && ( - {renderAccessLink(entry)} - )} - - {!entry.available && ( - - {t('tools.ask', { defaultValue: 'Ask' })} - - )} - {canConnectHosted && ( - - )} - {canAdd && ( - - )} - {renderDisconnect(entry, 'row')} - {canInstallGithubApp && ( - - )} - {renderManualRevokeNotice(entry)} -
- ); - }; - - const renderGithubAppSetup = () => { - if (!githubAppSetup) return null; - return ( - - ); - }; - - const renderDraft = () => { - if (!draft || !draftEntry) return null; - const podSeats = seats[draft.podId] || []; - const tools = draftTools(draftEntry, draft.writeMode); - const irreversible = irreversibleTools(draftEntry, tools); - const asks = draft.writeMode === 'read' - ? t('tools.asksNothing', { defaultValue: 'nothing asks first' }) - : draft.writeMode === 'write-with-confirm' - ? t('tools.asksEveryWrite', { defaultValue: 'every write asks first' }) - : (irreversible.length ? t('tools.asksList', { defaultValue: '{{tools}} ask first', tools: joinList(irreversible) }) : t('tools.asksNothing', { defaultValue: 'nothing asks first' })); - return ( - - ); - }; - - const renderAside = (grant: ToolGrant) => { - const dead = isDead(grant, now); - const podId = grantPodId(grant); - const granter = memberName(grant.grantedBy); - const revokedBy = memberName(grant.revokedBy); - const entry = entryFor(grant); - const counts = trail?.counts; - return ( - - ); - }; - - if (grants === null) { - return
{t('tools.loading', { defaultValue: 'Loading tools…' })}
; - } - if (grants.length === 0 && catalog.length === 0 && hiddenConnections.length === 0 && !removedHiddenNotice && !draft) return null; - - return ( -
-
-
-

{t('tools.title', { defaultValue: 'Tools' })}

- - {t('tools.grantedCount', { defaultValue: '{{count}} granted', count: grantedCount })} - {moreCount > 0 && ` · ${t('tools.moreCount', { defaultValue: '{{count}} more', count: moreCount })}`} - - setQuery(event.target.value)} - /> -
- {error && !draft && !selected && !githubAppSetup && ( -

{error}

- )} - {removedHiddenNotice &&

{removedHiddenNotice.revokePage - ? <>{t('tools.removedReviewProvider', { defaultValue: 'Connection removed. Review its access at {{label}}.', label: removedHiddenNotice.label })} {t('tools.openProviderSettings', { defaultValue: 'Open provider settings' })} - : t('tools.disconnected', { defaultValue: 'Disconnected {{label}}.', label: removedHiddenNotice.label })}

} -
- {liveRows.map(renderRow)} - {notYet.map(renderNotYet)} - - {inactiveRows.map(renderRow)} - {matchedHiddenConnections.map(renderHiddenConnection)} - - {liveRows.length === 0 && notYet.length === 0 && inactiveCount === 0 && ( -

{t('tools.nothingMatches', { defaultValue: 'Nothing matches.' })}

- )} -
-
- {draft ? renderDraft() : (selected ? renderAside(selected) : renderGithubAppSetup())} -
- ); -}; - -export default V2ConnectorTools; diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx deleted file mode 100644 index 43b683848a..0000000000 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ /dev/null @@ -1,1755 +0,0 @@ -// Connectors — the Signal Coverage artboard's channel list. It deliberately -// renders only controls the connector service already enforces: rows are keyed -// by the server's capability catalog (D8 Phase 2 D1/D2), every action is an -// existing lifecycle verb, and the aside's gate list writes only what the -// owner-only PATCH accepts (D3/D4). - -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { useV2Api } from '../hooks/useV2Api'; -import { useRelativeNow } from '../hooks/useRelativeNow'; -import { V2Pod, V2PodMember } from '../hooks/useV2Pods'; -import { PlatformGlyph } from '../icons/platforms'; -import { ActGlyph, MarkGlyph, MarkName } from '../icons/glyphs'; -import V2ConnectorTools, { type HostedMcpManualRevokeNotice } from './V2ConnectorTools'; -import { hostedMcpErrorNotice } from './hostedMcpErrorNotice'; -import { localizeRelativeTime } from '../utils/localizeRelativeTime'; -import { catalogDescription } from '../utils/connectorCatalogDescription'; -import { safeProviderRevokePage } from '../utils/safeProviderRevokePage'; -import V2PageHead from './V2PageHead'; -import V2InactiveFold from './V2InactiveFold'; - -interface ConnectorGate { - enabled?: boolean; - mode?: 'attention' | 'mirror'; - lead?: string; - since?: string; -} - -interface ConnectorConfig { - entryId?: string; - revokePage?: string; - chatTitle?: string; - // A linked Slack stores its workspace name here (slackOAuthService), never in - // chatTitle — so without this the row read "Slack · linked to …" beside the - // word Slack (TASK-156). - teamName?: string; - connectCode?: string; - connectCodeExpiresAt?: string; - liveRelay?: boolean; - relayAllAgentMessages?: boolean; - leadAgentUsername?: string; - gates?: Record; - // Projected by the server as { reason, at } only — the admin is never named. - adminPause?: { reason?: string; at?: string }; - pendingBind?: { - teamName?: string; - slackUserName?: string; - expiresAt?: string; - }; -} - -interface Connector { - _id: string; - installationId?: string; - type: string; - status: string; - // A reason this connector needs attention. Two writers share the field: our - // own classifier, and externalFeedService, which copies a provider's error - // text or a raw `err.message` in here. `errorMessageUserFacing` is what tells - // them apart, and only a message written for a person is rendered — the field - // holding a value is not permission to show it. - errorMessage?: string | null; - errorMessageUserFacing?: boolean; - scope?: 'user' | 'pod'; - isActive?: boolean; - createdAt?: string; - updatedAt?: string; - config?: ConnectorConfig; - podId?: { _id: string; name?: string } | string | null; -} - -interface CatalogInstallation { - status: string; - // The same pair the pod-scoped half reads, and the same rule: the field - // holding a value is not permission to show it. A catalog failure is - // `markProjectionFailure`'s raw exception message unless a writer says - // otherwise, so the flag decides and the generic sentence is the fallback - // (TASK-131). - errorMessage?: string; - errorMessageUserFacing?: boolean; - boundPodId?: string; - claimedAt?: string; - updatedAt?: string; - components?: { name?: string; status?: string; errorMessage?: string }[]; -} - -interface CatalogEntry { - installableId: string; - // Two lists (tools plan, option A): this page draws channels; the - // Tools page draws tool Installables, so a `tools` row never renders here. - list?: 'channels' | 'tools'; - connectionType?: 'github-app' | 'hosted-mcp'; - label?: string; - description?: string; - available: boolean; - // Capability vs offerability. `available` says this - // instance holds the credentials; `offered` says a builtin Installable row - // exists to install. Only both together may show an Add. Absent is read as - // offered: the field's only producer always emits it, so a missing value - // means a server older than this page, and defaulting the other way would - // blank every channel for the length of a rolling update instead of briefly - // restoring what this page did before the field existed. - offered?: boolean; - unavailableReason?: string; - // Tool entries provide the provider name for the hosted connection row. - entryId?: string; - installation: CatalogInstallation | null; - integration: Connector | null; -} - -interface CatalogResponse { - installables?: CatalogEntry[]; -} - -interface InstallResponse { - status?: 'active' | 'installing' | 'activating' | 'uninstalling'; - boundPodId?: string; -} - -interface InstallErrorResponse { - code?: string; - error?: string; - boundPodId?: string; -} - -interface SlackAuthorizeResponse { - authorizeUrl?: string; -} - -type ConnectorAction = - | 'manage' | 'show-code' | 'new-code' | 'authorize' | 'confirm' - | 'connect' | 'cancel' | 'retry' | 'retry-remove' | 'pick-pod'; - -interface ConnectorRow { - action: ConnectorAction | null; - actionLabel?: string; - detail: string; - dot: 'live' | 'idle' | 'pending' | 'empty' | 'not-yet'; - line: string; - // Direction A: relay mode is a category, so it is a mark on the row. `label` - // is the sentence (title + aria-label); `word` is what the 390 kicker shows, - // where tooltips do not exist. - mark?: { name: MarkName; label: string; word: string }; - muted?: boolean; - notEnabled?: boolean; - // TASK-024: a row whose provider is built but not offered here. It shares - // `notEnabled`'s row class and Ask link, and must not share its kicker — - // "not enabled" claims a missing credential this state does not have. - notConnectable?: boolean; - pulse: boolean; - secondary?: boolean; - when: string; -} - -// One list, two sources: the catalog keys a row per provider the instance -// knows about; legacy pod-scoped rows (no installation) render as before. -type ListItem = - | { kind: 'catalog'; key: string; entry: CatalogEntry; connector: Connector | null } - | { kind: 'legacy'; key: string; connector: Connector }; - -const TYPE_LABELS: Record = { - telegram: 'Telegram', - discord: 'Discord', - slack: 'Slack', - whatsapp: 'WhatsApp', - messenger: 'Messenger', - groupme: 'GroupMe', - x: 'X', - instagram: 'Instagram', - // Human-readable fallback only. These tool connections are still excluded - // from Channels by type in allItems below. - 'github-app': 'GitHub App', - 'hosted-mcp': 'Hosted tool', -}; - -const TOOL_ONLY_CONNECTION_TYPES = new Set(['github-app', 'hosted-mcp']); - -const mappedTypeLabel = (type: string): string | null => TYPE_LABELS[type] || null; - -// The onboarding sentence for a provider you can connect right now. Keyed by -// provider and deliberately without a default: an unmapped provider renders no -// detail line at all. The two-branch ternary this replaces gave every provider -// that was not telegram Slack's "one click in your workspace", so a stranger -// reading a Discord row was told to click something in a workspace they may not -// have. -const CONNECT_DETAIL: Record = { - telegram: { key: 'connectors.availableTelegram', defaultValue: 'one message' }, - slack: { key: 'connectors.availableSlack', defaultValue: 'one click in your workspace' }, -}; - -// Fallback only: when the catalog cannot be read the page still offers the -// two providers it can drive, exactly as it did before the catalog existed. -const ADD_PLATFORMS = [ - { type: 'telegram', enabled: true }, - { type: 'slack', enabled: true }, -]; -// Providers we have not built at all. This is NOT the same claim as either -// state the catalog can report: "not enabled on this instance" is a missing -// credential, and "not connectable yet" is a provider we have built and this -// instance cannot offer because no builtin Installable row carries it. Both of -// those arrive through the catalog, with their own state line, so this list -// holds only the providers that have no manifest to arrive through. Discord -// used to sit here, which made the page say "we don't build this" about a -// shipping connector (routes/discord.ts) whose only defect was a missing -// readiness() declaration; it now renders the roster state line instead. -const UNAVAILABLE_PLATFORM_LABELS = ['WhatsApp']; - -const BOT_HANDLE = process.env.REACT_APP_TELEGRAM_BOT_HANDLE || ''; -const RECENT_MS = 10 * 60_000; -// Mirrors INSTALL_LOCK_TTL_MS on the server: Cancel appears exactly when the -// lifecycle verb would honour it (a stale claim), never before. -export const INSTALL_LOCK_TTL_MS = 60_000; -const TRANSIENT_STATUSES = ['installing', 'activating', 'uninstalling']; - -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 || {} -); - -// Row C: the Slack authorize and bind verbs refuse with a `code` the server -// already sends (`slackError` in backend/routes/installables.ts), so the row -// can say which refusal it was rather than one generic apology. An unlisted -// code falls back to the server's own sentence, then to the generic copy. -const SLACK_REFUSALS: Record = { - slack_already_authorized: { key: 'connectors.slackAlreadyAuthorized', defaultValue: 'Slack is already awaiting confirmation or connected.' }, - slack_authorization_unavailable: { key: 'connectors.slackAuthorizationUnavailable', defaultValue: 'Slack authorization is no longer available.' }, - slack_installation_not_found: { key: 'connectors.slackInstallationNotFound', defaultValue: 'Slack installation not found.' }, - slack_bind_missing: { key: 'connectors.slackBindMissing', defaultValue: 'There is no Slack authorization to confirm.' }, - slack_bind_expired: { key: 'connectors.slackBindExpired', defaultValue: 'Slack authorization expired. Start again.' }, - slack_lead_invalid: { key: 'connectors.slackLeadInvalid', defaultValue: 'Choose an agent in the active pod.' }, - slack_pod_access_denied: { key: 'connectors.slackPodAccessDenied', defaultValue: 'You no longer have access to this pod.' }, -}; - -const connectorPodId = (connector: Connector | null | undefined): string | null => { - if (!connector || !connector.podId) return null; - return typeof connector.podId === 'object' ? String(connector.podId._id || '') || null : String(connector.podId); -}; - -const groupCode = (code: string): string => (code.match(/.{1,4}/g) || [code]).join(' '); - -// Mirrors `PERSONAL_POD_TYPES` in `backend/services/podTypePolicyService.ts` — -// private conversation surfaces, not rooms a channel should join by default. -// `/api/pods` returns the caller's own 1:1 room first, so TASK-155 shipped with -// the picker defaulting to "Scout (Default)", an agent-room. The pods stay in -// the list (a DM is a legal target) — they just never become the default. -const PERSONAL_POD_TYPES = new Set(['agent-admin', 'agent-room', 'agent-dm']); - -const defaultPodForChannel = (pods: V2Pod[]): string => ( - (pods.find((pod) => !PERSONAL_POD_TYPES.has(String(pod.type))) || pods[0])?._id || '' -); - -const codeIsLive = (connector: Connector): boolean => Boolean( - connector.config?.connectCode - && connector.config?.connectCodeExpiresAt - && new Date(connector.config.connectCodeExpiresAt).getTime() > Date.now(), -); - -const codeExpiresInMinutes = (connector: Connector): number => { - const expiry = connector.config?.connectCodeExpiresAt - ? new Date(connector.config.connectCodeExpiresAt).getTime() - : NaN; - if (!Number.isFinite(expiry)) return 0; - return Math.max(1, Math.ceil((expiry - Date.now()) / 60_000)); -}; - -// One connector's age as English prose uses this profile; the gate line below -// needed the same parts (TASK-164). -const connectorRelativeTimeOptions = { includeFuture: false, missing: 'just now', rounding: 'floor' } as const; - -const claimIsStale = (installation: CatalogInstallation): boolean => { - const claimedAt = installation.claimedAt ? new Date(installation.claimedAt).getTime() : NaN; - if (!Number.isFinite(claimedAt)) return true; - return Date.now() - claimedAt >= INSTALL_LOCK_TTL_MS; -}; - -const projectionMissing = (entry: CatalogEntry): boolean => ( - !entry.integration - || entry.integration.isActive === false - || (entry.installation?.components || []).some((component) => component.status === 'stale') -); - -const botMembers = (pod: V2Pod | undefined): V2PodMember[] => ( - (pod?.members || []).filter((member): member is V2PodMember => ( - typeof member === 'object' && member !== null && Boolean(member.isBot) && Boolean(member.username) - )) -); - -const V2ConnectorsPage: React.FC = () => { - const { t, i18n } = useTranslation(); - const api = useV2Api(); - const navigate = useNavigate(); - const [connectors, setConnectors] = useState([]); - const [catalog, setCatalog] = useState(null); - const [hostedCatalog, setHostedCatalog] = useState(null); - // `null` means membership is not available yet (or the read failed). An - // empty array is the only confirmed zero-pod state, so it alone may replace - // the existing picker with the create-a-pod path. - const [pods, setPods] = useState(null); - const [newPodId, setNewPodId] = useState(''); - const [addingType, setAddingType] = useState(null); - const [loading, setLoading] = useState(true); - const [busyId, setBusyId] = useState(null); - const [creating, setCreating] = useState(false); - const [selectedKey, setSelectedKey] = useState(null); - const [confirmRemove, setConfirmRemove] = useState(null); - const [expandedGate, setExpandedGate] = useState(null); - const [copied, setCopied] = useState(null); - const [error, setError] = useState(null); - const [hostedMcpManualRevoke, setHostedMcpManualRevoke] = useState(null); - // Row C: a refusal raised by a row's own button belongs on that row, not - // only in the page-level slot at the foot of the page. - const [rowRefusal, setRowRefusal] = useState<{ key: string; message: string } | null>(null); - const [slackCallbackError, setSlackCallbackError] = useState(null); - const [slackLeadChoices, setSlackLeadChoices] = useState>({}); - const [hostedMcpCallbackError, setHostedMcpCallbackError] = useState(null); - const pollRef = useRef | null>(null); - const podPickerRef = useRef(null); - // Ages must advance while the page sits open, and the source must be re-read - // when the tab comes back: a row that says `since 5m ago` is wrong twice - // over if it is still saying it an hour later (TASK-131). - const now = useRelativeNow(); - const adding = addingType !== null; - const podList = pods || []; - // The gate mode options, declared here rather than inline in the JSX: eslint's - // i18next/no-literal-string reads an array literal in JSX as copy, and only the - // second element of each pair is copy (TASK-164). - const gateModes: [ConnectorGate['mode'], string][] = [ - [undefined, t('connectors.modeDefault', { defaultValue: 'Default' })], - ['attention', t('connectors.modeAttention', { defaultValue: 'Attention' })], - ['mirror', t('connectors.modeMirror', { defaultValue: 'Mirror' })], - ]; - - // TASK-155: at 390 the aside sits below the fold of the page's own scroller - // (`v2-feature__body` is the scroller, not the document), so opening the form - // changed nothing on screen even once it moved into the panel. Focusing the - // picker scrolls it into view, and is where a keyboard user wants the cursor - // anyway. Keyed on the type so switching provider also lands on the picker. - useEffect(() => { - if (addingType) podPickerRef.current?.focus(); - }, [addingType]); - - const load = useCallback(async () => { - try { - const [rows, catalogResponse] = await Promise.all([ - api.get('/api/integrations/user/all'), - // The catalog is the page's authority for provider rows; if it cannot - // be read the legacy list still renders and the picker falls back. - api.get('/api/installables').catch(() => null), - ]); - const connectorRows = Array.isArray(rows) ? rows : []; - const installables = catalogResponse && Array.isArray(catalogResponse.installables) - ? catalogResponse.installables - : null; - setConnectors(connectorRows); - setCatalog(installables ? installables.filter((entry) => entry.list !== 'tools') : null); - setHostedCatalog(installables - ? installables.filter((entry) => entry.list === 'tools' && entry.connectionType === 'hosted-mcp') - : null); - setError(null); - return connectorRows; - } catch { - setError(t('connectors.loadError', { defaultValue: 'Could not load connectors.' })); - return []; - } finally { - setLoading(false); - } - }, [api, t]); - - useEffect(() => { void load(); }, [load]); - - useEffect(() => { - const onVisibility = () => { if (document.visibilityState === 'visible') void load(); }; - document.addEventListener('visibilitychange', onVisibility); - return () => document.removeEventListener('visibilitychange', onVisibility); - }, [load]); - - // Keep vendor-controlled scope values out of the URL and official UI copy. - // Consume the callback result after displaying a fixed notice. - useEffect(() => { - const query = new URLSearchParams(window.location.search); - if (query.get('hostedMcp') !== 'error') return; - setHostedMcpCallbackError(hostedMcpErrorNotice(t, query.get('code'))); - query.delete('hostedMcp'); - query.delete('code'); - query.delete('extraScope'); - query.delete('error'); - query.delete('error_description'); - const remaining = query.toString(); - window.history.replaceState( - window.history.state, - '', - `${window.location.pathname}${remaining ? `?${remaining}` : ''}${window.location.hash}`, - ); - }, [t]); - - // The Slack callback resolves in its own tab. Consume its opaque result and - // leave no state or error code in the browser URL. - 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]); - - // The hosted-MCP callback says when a previous provider authorization may - // need manual attention. Resolve its page from the authenticated connection - // row and match the callback copy to that server-owned value; never turn an - // arbitrary query parameter into a provider link. - useEffect(() => { - const query = new URLSearchParams(window.location.search); - if (query.get('hostedMcp') !== 'connected') return; - // Let the ordinary page load finish first; its authenticated row is the - // authority for the provider link, so there is no second request here. - if (loading || hostedCatalog === null) return; - const entryId = query.get('entryId'); - const revokeAt = query.get('revokeAt'); - if (entryId && revokeAt) { - const row = connectors.find((candidate) => ( - candidate.type === 'hosted-mcp' && candidate.config?.entryId === entryId - )); - const revokePage = row?.config?.revokePage; - const provider = hostedCatalog?.find((entry) => entry.entryId === entryId)?.label; - if (row && provider && revokePage === revokeAt && safeProviderRevokePage(revokePage)) { - setHostedMcpManualRevoke({ - connectionId: row._id, - entryId, - provider, - revokePage, - }); - } - } - query.delete('hostedMcp'); - query.delete('entryId'); - query.delete('revokeAt'); - query.delete('code'); - const remaining = query.toString(); - window.history.replaceState( - window.history.state, - '', - `${window.location.pathname}${remaining ? `?${remaining}` : ''}${window.location.hash}`, - ); - }, [connectors, hostedCatalog, loading]); - - const hasPending = connectors.some((connector) => connector.status !== 'connected' && ( - (connector.type === 'telegram' && codeIsLive(connector)) - || (connector.type === 'slack' && (codeIsLive(connector) || Boolean(connector.config?.pendingBind))) - )) || (catalog || []).some((entry) => ( - entry.installation ? TRANSIENT_STATUSES.includes(entry.installation.status) : false - )); - useEffect(() => { - if (hasPending && !pollRef.current) pollRef.current = setInterval(load, 3000); - if (!hasPending && pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - return () => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - }; - }, [hasPending, load]); - - useEffect(() => { - let cancelled = false; - void (async () => { - try { - // The server decides this. A pod the user is listed in is a connector - // target UNLESS its type is one the default listing hides (`agent-admin`): - // installing into one, gating one, or making one active all 403, so this - // list — built from that same endpoint — offers exactly what those verbs - // accept (TASK-171). - const data = await api.get('/api/pods'); - const eligible = Array.isArray(data) ? data : []; - if (!cancelled) { - setPods(eligible); - setNewPodId((current) => current || defaultPodForChannel(eligible)); - } - } catch { - // Keep the normal picker visible when membership cannot be read. A - // zero-pod CTA requires a confirmed `[]`, never an unavailable read. - } - })(); - return () => { cancelled = true; }; - }, [api]); - - const untitledPodName = (): string => t('connectors.untitledPod', { defaultValue: 'Untitled pod' }); - - const podNameById = (podId: string | null | undefined, fallback?: Connector | null): string => { - const pod = podList.find((candidate) => String(candidate._id) === String(podId || '')); - if (pod) return pod.name || untitledPodName(); - if (fallback && typeof fallback.podId === 'object' && fallback.podId?.name) return fallback.podId.name; - return untitledPodName(); - }; - - const boundPodName = (boundPodId: string | undefined): string => { - const existing = connectors.find((connector) => connectorPodId(connector) === String(boundPodId)); - const boundPod = podList.find((pod) => String(pod._id) === String(boundPodId)); - return existing - ? podNameById(connectorPodId(existing), existing) - : (boundPod?.name || t('connectors.anotherPod', { defaultValue: 'another pod' })); - }; - - const installInProgressMessage = (boundPodId?: string): string => ( - boundPodId - ? t('connectors.installInProgressForPod', { - defaultValue: 'Still setting up for {{pod}} — try again in a moment.', - pod: boundPodName(boundPodId), - }) - : t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' }) - ); - - const install = async (type: string, podId: string): Promise => { - const typeLabel = TYPE_LABELS[type] || type; - try { - const result = await api.post(installableLifecyclePath(type), { podId }); - // Reload before reporting: `load` clears the error slot on success, and - // the row's new state is what the message refers to. - await load(); - if (result.status && result.status !== 'active') { - setError(installInProgressMessage(result.boundPodId)); - return false; - } - return true; - } catch (requestError) { - const response = installErrorResponse(requestError); - let message: string; - if (response.status === 409 && response.data?.code === 'install_in_progress') { - message = installInProgressMessage(response.data.boundPodId); - } else if (response.status === 409 && response.data?.code === 'already_installed') { - message = t('connectors.alreadyBound', { - // TASK-173: name the controls the page actually shows. "gate" is - // this file's internal name for the switches (ConnectorGate, - // expandedGate, writeGate) and it had leaked into the only - // user-visible string on the page that used it. The section is - // `connectors.gatesTitle` ("Pods that reach this channel", :1106). - // - // The aside that HOLDS that section is labelled "Channel details" - // (:1220, :1286) — an `aria-label`, not visible text, so naming it in - // the copy sent a sighted user hunting for a string the page never - // renders. That is the same defect as "gate switches" sourced from an - // attribute instead of an identifier (TASK-173 gate), so the - // copy names the section title alone. - defaultValue: 'Your {{connector}} channel is bound to {{pod}}. Other pods reach it through "Pods that reach this channel" — switch a pod on there.', - connector: typeLabel, - pod: boundPodName(response.data.boundPodId), - }); - } else if (response.status === 409 && response.data?.code === 'installation_paused') { - message = t('connectors.pausedRefusal', { - defaultValue: 'An administrator paused this connector. Ask your operator.', - }); - } else if (response.status === 422 && response.data?.code === 'provider_not_configured') { - message = t('connectors.providerNotConfigured', { - defaultValue: '{{connector}} is not enabled on this instance. Ask your operator.', - connector: typeLabel, - }); - } else { - message = t('connectors.createError', { defaultValue: 'Could not create the connector.' }); - } - await load(); - setError(message); - return false; - } - }; - - const createConnector = async () => { - if (!newPodId || !addingType || creating) return; - setCreating(true); - setError(null); - try { - const installed = await install(addingType, newPodId); - if (installed) setAddingType(null); - } finally { - setCreating(false); - } - }; - - const patchIntegration = async (connector: Connector, body: Record, errorKey: string, errorDefault: string) => { - if (busyId) return; - setBusyId(connector._id); - setError(null); - try { - await api.patch(`/api/integrations/${connector._id}`, body); - await load(); - } catch { - setError(t(errorKey, { defaultValue: errorDefault })); - } finally { - setBusyId(null); - } - }; - - const patchConfig = (connector: Connector, config: Record, errorKey: string, errorDefault: string) => ( - patchIntegration(connector, { config }, errorKey, errorDefault) - ); - - // The PATCH merges `config` one level deep, so `gates` is written whole: - // every pod's gate travels with the one that changed, `since` included. - // The server validates every key against membership and refuses the whole - // body on the first miss, so a gate for a pod the owner has since left - // (not yet pruned by the reconciler) is dropped here rather than sent — - // otherwise every toggle would 403 for up to one sweep. - const writeGate = (connector: Connector, podId: string, next: ConnectorGate) => { - const current = connector.config?.gates || {}; - const memberOf = new Set(podList.map((pod) => String(pod._id))); - const gates: Record = {}; - Object.entries(current).forEach(([key, gate]) => { - if (!memberOf.has(String(key))) return; - gates[key] = { - enabled: gate.enabled === true, - ...(gate.mode ? { mode: gate.mode } : {}), - ...(gate.lead ? { lead: gate.lead } : {}), - since: gate.since || new Date().toISOString(), - }; - }); - gates[podId] = { - enabled: next.enabled === true, - ...(next.mode ? { mode: next.mode } : {}), - ...(next.lead ? { lead: next.lead } : {}), - since: current[podId]?.since || new Date().toISOString(), - }; - return patchConfig(connector, { gates }, 'connectors.gateError', 'Could not update that pod.'); - }; - - const makeActive = (connector: Connector, podId: string) => ( - patchIntegration(connector, { podId }, 'connectors.activePodError', 'Could not change the active pod.') - ); - - const regenerateCode = async (connector: Connector) => { - if (busyId) return; - setBusyId(connector._id); - setError(null); - try { - await api.post(`/api/integrations/${connector._id}/connect-code`, {}); - await load(); - } catch { - setError(t('connectors.codeError', { defaultValue: 'Could not create a new code.' })); - } finally { - setBusyId(null); - } - }; - - // The refusal is named from the response body, not swallowed: a bare catch - // here is what hid `slack_already_authorized` from the row (Row C). - const refusalCopy = (requestError: unknown, fallbackKey: string, fallbackDefault: string): string => { - const { data } = installErrorResponse(requestError); - const known = data?.code ? SLACK_REFUSALS[data.code] : undefined; - if (known) return t(known.key, { defaultValue: known.defaultValue }); - if (data?.error) return data.error; - return t(fallbackKey, { defaultValue: fallbackDefault }); - }; - - const authorizeSlack = async (connector: Connector, rowKey: string) => { - if (busyId) return; - const authorizationWindow = window.open('', '_blank'); - setBusyId(connector._id); - setError(null); - setRowRefusal(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'); - if (authorizationWindow) { - authorizationWindow.opener = null; - authorizationWindow.location.assign(result.authorizeUrl); - } else { - window.location.assign(result.authorizeUrl); - } - } catch (requestError) { - authorizationWindow?.close(); - setRowRefusal({ - key: rowKey, - message: refusalCopy(requestError, 'connectors.slackAuthorizeError', 'Could not begin Slack authorization. Try again in a moment.'), - }); - } finally { - setBusyId(null); - } - }; - - const resolveSlackBind = async (connector: Connector, action: 'confirm' | 'reject', rowKey: string, lead?: string) => { - if (busyId) return; - setBusyId(connector._id); - setError(null); - setRowRefusal(null); - try { - await api.post(`/api/installables/slack/${action}`, action === 'confirm' && lead - ? { leadAgentUsername: lead } : {}); - await load(); - } catch (requestError) { - setRowRefusal({ - key: rowKey, - message: refusalCopy(requestError, 'connectors.slackBindError', 'Could not update the Slack connection. Try again in a moment.'), - }); - } finally { - setBusyId(null); - } - }; - - // DELETE on the lifecycle verb serves Remove, Cancel and Retry remove alike: - // the service decides what a DELETE means from the parent's state. - const uninstall = async (type: string, busyKey: string, errorKey: string, errorDefault: string) => { - if (busyId) return; - setBusyId(busyKey); - setError(null); - try { - await api.del(installableLifecyclePath(type)); - setConfirmRemove(null); - } catch (requestError) { - const response = installErrorResponse(requestError); - // A live claim refuses Cancel; the row simply reads "Setting up…" again. - if (!(response.status === 409 && response.data?.code === 'install_in_progress')) { - setError(t(errorKey, { defaultValue: errorDefault })); - } - } finally { - setBusyId(null); - await load(); - } - }; - - const remove = async (item: ListItem) => { - const connector = item.connector; - if (item.kind === 'catalog') { - await uninstall(item.entry.installableId, item.key, 'connectors.removeError', 'Could not remove the channel.'); - setSelectedKey(null); - return; - } - if (!connector) return; - setBusyId(connector._id); - try { - if (connector.installationId) { - await api.del(installableLifecyclePath(connector.type)); - } else { - await api.patch(`/api/integrations/${connector._id}`, { isActive: false }); - } - setConfirmRemove(null); - setSelectedKey(null); - await load(); - } catch { - setError(t('connectors.removeError', { defaultValue: 'Could not remove the channel.' })); - } finally { - setBusyId(null); - } - }; - - const retry = async (entry: CatalogEntry) => { - if (busyId) return; - const podId = entry.installation?.boundPodId || connectorPodId(entry.integration) || newPodId; - if (!podId) { - setAddingType(entry.installableId); - return; - } - setBusyId(entry.installableId); - setError(null); - try { - await install(entry.installableId, podId); - } finally { - setBusyId(null); - } - }; - - const copyCommand = async (code: string) => { - const command = `/commonly-enable ${code}`; - try { - await navigator.clipboard.writeText(command); - setCopied(code); - setTimeout(() => setCopied(null), 2000); - } catch { - // Clipboard access is optional; the command remains selectable. - } - }; - - // The age as a unit-suffixed number from the timestamp ("23d", "5m", "just now"), - // and a verb line built from a key — the kicker's source of truth. - const shortAge = (date?: string | null): string => { - const timestamp = date ? new Date(date).getTime() : NaN; - if (!Number.isFinite(timestamp)) return t('time.age.justNow', { defaultValue: 'just now' }); - const minutes = Math.max(0, Math.floor((now - timestamp) / 60_000)); - if (minutes < 1) return t('time.age.justNow', { defaultValue: 'just now' }); - if (minutes < 60) return t('time.age.minutes', { defaultValue: '{{n}}m', n: minutes }); - const hours = Math.floor(minutes / 60); - if (hours < 24) return t('time.age.hours', { defaultValue: '{{n}}h', n: hours }); - return t('time.age.days', { defaultValue: '{{n}}d', n: Math.floor(hours / 24) }); - }; - const ageLine = (verb: 'added' | 'started' | 'since' | 'paused' | 'slackAnswered', date?: string | null): string => { - const age = shortAge(date); - switch (verb) { - case 'added': return t('connectors.age.added', { defaultValue: 'added {{age}}', age }); - case 'started': return t('connectors.age.started', { defaultValue: 'started {{age}}', age }); - case 'since': return t('connectors.age.since', { defaultValue: 'since {{age}}', age }); - case 'paused': return t('connectors.age.paused', { defaultValue: 'paused {{age}}', age }); - default: return t('connectors.age.slackAnswered', { defaultValue: 'Slack answered {{age}}', age }); - } - }; - - const rowFor = (connector: Connector): ConnectorRow => { - const started = ageLine('started', connector.createdAt); - const isTelegram = connector.type === 'telegram'; - const isSlack = connector.type === 'slack'; - const title = connector.config?.chatTitle || connector.config?.teamName || TYPE_LABELS[connector.type] || connector.type; - - if (connector.status === 'error') { - return { - action: isTelegram ? 'new-code' : isSlack ? 'authorize' : null, - actionLabel: isTelegram ? t('connectors.newCode', { defaultValue: 'New code' }) : t('connectors.slackAuthorize', { defaultValue: 'Authorize in Slack' }), - detail: t('connectors.errorReconnect', { defaultValue: 'reconnect to resume' }), - dot: 'empty', - line: connector.errorMessageUserFacing && connector.errorMessage - ? connector.errorMessage - : t('connectors.errorLine', { defaultValue: 'The connection dropped.' }), - pulse: false, - when: ageLine('since', connector.updatedAt || connector.createdAt), - }; - } - - if (connector.status === 'connected') { - const activePodId = connectorPodId(connector); - if (!activePodId) { - // The owner left the active pod and the prune cleared it (D3). Their - // typed messages have nowhere to go until they pick a pod in the aside. - return { - action: 'pick-pod', - actionLabel: t('connectors.pickPod', { defaultValue: 'Pick a pod' }), - detail: t('connectors.notLinkedDetail', { defaultValue: 'your messages have nowhere to go' }), - dot: 'idle', - line: t('connectors.notLinkedLine', { defaultValue: '{{title}} · not linked to a pod', title }), - pulse: false, - when: ageLine('added', connector.createdAt), - }; - } - const relay = Boolean(connector.config?.liveRelay); - const mirror = connector.config?.relayAllAgentMessages === true; - const recent = connector.updatedAt && (Date.now() - new Date(connector.updatedAt).getTime()) < RECENT_MS; - const markName: MarkName = relay ? (mirror ? 'mirror' : 'attention') : 'off'; - const markLabel = relay - ? (mirror - ? t('connectors.rowMirror', { defaultValue: 'every agent line reaches the channel' }) - : t('connectors.rowAttention', { defaultValue: 'escalations reach the channel' })) - : t('connectors.rowRelayOff', { defaultValue: 'messages stay in the pod' }); - return { - action: (isTelegram || isSlack) ? 'manage' : null, - actionLabel: t('connectors.manage', { defaultValue: 'Manage' }), - detail: '', - mark: { - name: markName, - label: markLabel, - word: relay - ? (mirror ? t('connectors.kickerMirror', { defaultValue: 'mirror' }) : t('connectors.kickerAttention', { defaultValue: 'attention' })) - : t('connectors.kickerOff', { defaultValue: 'relay off' }), - }, - dot: relay ? 'live' : 'idle', - line: t('connectors.linkedLine', { defaultValue: '{{title}} · linked to {{pod}}', title, pod: podNameById(activePodId, connector) }), - pulse: relay && Boolean(recent), - secondary: true, - when: ageLine('added', connector.createdAt), - }; - } - - if (isTelegram && codeIsLive(connector)) { - return { - action: 'show-code', - actionLabel: t('connectors.showCode', { defaultValue: 'Show code' }), - detail: t('connectors.codeExpires', { - defaultValue: 'Code expires in {{minutes}} min', - minutes: codeExpiresInMinutes(connector), - }), - dot: 'pending', - line: t('connectors.waitingTelegram', { - defaultValue: 'Send /commonly-enable in your Telegram chat.', - }), - pulse: true, - when: started, - }; - } - - if (isTelegram) { - return { - action: 'new-code', - actionLabel: t('connectors.newCode', { defaultValue: 'New code' }), - detail: t('connectors.nothingSent', { defaultValue: 'nothing was sent' }), - dot: 'empty', - line: t('connectors.codeExpired', { defaultValue: 'The enable code expired.' }), - pulse: false, - when: started, - }; - } - - if (isSlack && connector.config?.pendingBind) { - const workspace = connector.config.pendingBind.teamName || t('connectors.thisSlackWorkspace', { defaultValue: 'This Slack workspace' }); - const user = connector.config.pendingBind.slackUserName - ? `@${connector.config.pendingBind.slackUserName}` - : t('connectors.yourSlackUser', { defaultValue: 'your Slack user' }); - return { - action: 'confirm', - actionLabel: t('connectors.slackConfirm', { defaultValue: 'Confirm' }), - detail: t('connectors.slackConfirmDetail', { defaultValue: 'waiting for you to confirm' }), - dot: 'pending', - line: t('connectors.slackConfirmRow', { defaultValue: '{{workspace}} says {{user}} connected — is that you?', workspace, user }), - pulse: true, - when: ageLine('slackAnswered', connector.updatedAt || connector.createdAt), - }; - } - - if (isSlack) { - return { - action: 'authorize', - actionLabel: t('connectors.slackAuthorize', { defaultValue: 'Authorize in Slack' }), - detail: t('connectors.slackAuthorizeDetail', { defaultValue: 'one click in Slack' }), - dot: 'empty', - line: t('connectors.slackAuthorizeHint', { defaultValue: 'Authorize Commonly in your Slack workspace to connect your DM.' }), - pulse: false, - when: started, - }; - } - - return { - action: null, - detail: t('connectors.pending', { defaultValue: 'Waiting for the channel' }), - dot: 'empty', - line: t('connectors.waitingToConnect', { defaultValue: '{{label}} is waiting to connect.', label: TYPE_LABELS[connector.type] || connector.type }), - pulse: false, - when: started, - }; - }; - - // D2: the catalog entry and its parent decide the row; the Integration row - // only takes over once the parent is `active` and its projection is whole. - const rowForEntry = (entry: CatalogEntry, connector: Connector | null): ConnectorRow => { - const label = entry.label || TYPE_LABELS[entry.installableId] || entry.installableId; - const installation = entry.installation; - if (!entry.available) { - return { - action: null, - // The copy decision here is main's, not this branch's: this commit set - // `detail: ''` citing the 09-04 one-state-line rule, but #1557 landed - // the detail AFTER that ruling and its tests assert the text is present. - // A rebase is not the place to re-open it, so the line is restored and - // raised on the row; everything else in the commit is unchanged. - detail: t('connectors.askOperator', { defaultValue: 'ask your operator' }), - dot: 'not-yet', - line: t('connectors.notEnabled', { defaultValue: 'Not offered on this instance.' }), - muted: true, - notEnabled: true, - pulse: false, - when: '—', - }; - } - // Usable here, but with nothing to install: declaring readiness is a claim - // about this instance's keys, not about whether a row exists to install. - // Showing the Add anyway is what produced a lowercase `discord` row whose - // click ended in 404 installable_not_found. - if (entry.offered === false) { - return { - action: null, - detail: '', - dot: 'not-yet', - line: t('connectors.notConnectable', { defaultValue: 'Unavailable here.' }), - muted: true, - // The class and the Ask link are right for this row; the kicker was the - // part that lied. Measured in the keys-set render at #1826: the eyebrow - // read "not enabled" directly above "Not connectable yet.", i.e. asking - // an operator for credentials the operator had already installed. - notConnectable: true, - notEnabled: true, - pulse: false, - when: '—', - }; - } - if (!installation) { - const connectDetail = CONNECT_DETAIL[entry.installableId]; - return { - action: 'connect', - // The row action opens the pod picker; the form's Connect button is - // the actual install action. Distinct labels keep the two-step flow - // legible to a stranger. - actionLabel: t('connectors.add', { defaultValue: 'Add' }), - detail: connectDetail - ? t(connectDetail.key, { defaultValue: connectDetail.defaultValue }) - : '', - dot: 'empty', - line: catalogDescription(i18n, entry.installableId, entry.description) || t('connectors.availableLine', { defaultValue: 'Connect {{label}} to a pod.', label }), - pulse: false, - when: t('connectors.notConnected', { defaultValue: 'not connected' }), - }; - } - const since = ageLine('since', installation.updatedAt); - const started = ageLine('started', installation.claimedAt || installation.updatedAt); - const stale = claimIsStale(installation); - if (installation.status === 'installing' || installation.status === 'activating') { - return stale - ? { - action: 'cancel', - actionLabel: t('connectors.cancel', { defaultValue: 'Cancel' }), - detail: t('connectors.serverLetGo', { defaultValue: 'the server let go of it' }), - dot: 'pending', - line: t('connectors.setupSlow', { defaultValue: 'Setup is taking longer than it should.' }), - pulse: true, - secondary: true, - when: started, - } - : { - action: null, - detail: t('connectors.waitingServer', { defaultValue: 'waiting for the server' }), - dot: 'pending', - line: t('connectors.settingUp', { defaultValue: 'Setting up…' }), - pulse: true, - when: started, - }; - } - if (installation.status === 'uninstalling') { - return stale - ? { - action: 'retry-remove', - actionLabel: t('connectors.retryRemove', { defaultValue: 'Retry remove' }), - detail: t('connectors.serverLetGo', { defaultValue: 'the server let go of it' }), - dot: 'pending', - line: t('connectors.removeSlow', { defaultValue: 'Removal is taking longer than it should.' }), - pulse: true, - secondary: true, - when: started, - } - : { - action: null, - detail: t('connectors.waitingServer', { defaultValue: 'waiting for the server' }), - dot: 'pending', - line: t('connectors.removing', { defaultValue: 'Removing…' }), - pulse: true, - when: started, - }; - } - if (installation.status === 'error') { - return { - action: 'retry', - actionLabel: t('connectors.retry', { defaultValue: 'Retry' }), - detail: t('connectors.retryOrRemove', { defaultValue: 'retry, or remove it' }), - dot: 'empty', - line: installation.errorMessageUserFacing && installation.errorMessage - ? installation.errorMessage - : t('connectors.setupFailed', { defaultValue: 'Setup didn’t finish.' }), - pulse: false, - when: since, - }; - } - if (installation.status === 'paused') { - const pause = connector?.config?.adminPause; - const reason = pause?.reason ? ` ${pause.reason}` : ''; - return { - action: null, - detail: t('connectors.askOperator', { defaultValue: 'ask your operator' }), - dot: 'empty', - line: `${t('connectors.pausedLine', { defaultValue: 'Paused by an administrator.' })}${reason}`, - pulse: false, - when: ageLine('paused', pause?.at || installation.updatedAt), - }; - } - if (installation.status === 'stale') { - return { - action: null, - detail: t('connectors.notRelaying', { defaultValue: 'not relaying' }), - dot: 'empty', - line: t('connectors.pausedShort', { defaultValue: 'Paused.' }), - pulse: false, - when: since, - }; - } - if (projectionMissing(entry) || !connector) { - return { - action: 'retry', - actionLabel: t('connectors.retry', { defaultValue: 'Retry' }), - detail: t('connectors.retryRebuilds', { defaultValue: 'retry rebuilds it' }), - dot: 'empty', - line: t('connectors.recordGone', { defaultValue: 'The channel record is gone.' }), - pulse: false, - when: since, - }; - } - return rowFor(connector); - }; - - const connectorForEntry = (entry: CatalogEntry): Connector | null => { - if (!entry.integration) return null; - const podId = connectorPodId(entry.integration); - return { - ...entry.integration, - type: entry.integration.type || entry.installableId, - installationId: entry.integration.installationId || 'catalog', - podId: podId ? { _id: podId, name: podNameById(podId, entry.integration) } : null, - }; - }; - - const catalogTypes = new Set((catalog || []).map((entry) => entry.installableId)); - // D215: a provider this instance does not offer is not listed, so a connector - // nobody here has walked is never shown. The API still reports it (the - // offerability contract), and someone who already installed it keeps their - // row, which renders in the not-connectable state below. - const listedCatalog = (catalog || []).filter((entry) => ( - entry.offered !== false || Boolean(entry.installation) || Boolean(entry.integration) - )); - const allItems: ListItem[] = [ - ...listedCatalog.map((entry): ListItem => ({ - kind: 'catalog', - key: `catalog:${entry.installableId}`, - entry, - connector: connectorForEntry(entry), - })), - // Rows the catalog does not describe: legacy pod-scoped connectors, and - // installable rows only while the catalog itself cannot be read. - ...connectors - .filter((connector) => ( - !TOOL_ONLY_CONNECTION_TYPES.has(connector.type) - && !(connector.installationId && catalogTypes.has(connector.type)) - )) - .map((connector): ListItem => ({ kind: 'legacy', key: `legacy:${connector._id}`, connector })), - ]; - - const rowForItem = (item: ListItem): ConnectorRow => ( - item.kind === 'catalog' ? rowForEntry(item.entry, item.connector) : rowFor(item.connector) - ); - - const itemLabel = (item: ListItem): string | null => ( - item.kind === 'catalog' - ? (item.entry.label?.trim() || mappedTypeLabel(item.entry.installableId)) - : mappedTypeLabel(item.connector.type) - ); - - // A backend type is an identifier, not a customer-facing name. Unknown types - // have no Channels row until the catalog provides a label or this map does. - const items = allItems.filter((item) => Boolean(itemLabel(item))); - const connectedCount = items.filter((item) => item.connector?.status === 'connected').length; - const needsYouCount = items.filter((item) => ( - item.connector?.status === 'error' - || (item.kind === 'catalog' && item.entry.installation?.status === 'error') - || (item.connector?.status === 'connected' && !connectorPodId(item.connector)) - || Boolean(item.connector?.config?.pendingBind) - )).length; - const headMeta = [ - connectedCount ? t('connectors.counts.connected', { count: connectedCount }) : null, - needsYouCount ? t('connectors.counts.needsYou', { count: needsYouCount }) : null, - ].filter(Boolean).join(' · '); - - const itemType = (item: ListItem): string => (item.kind === 'catalog' ? item.entry.installableId : item.connector.type); - - // Direction A rule 3: every row's kicker is `pod · verb age` in mono. The verb - // stays ("added 23d", never "23d") so an age beside a state dot is not read as - // last-used; a connection with no pod says so. Both parts come from keys and - // the timestamp, never from rendered English. - const kickerFor = (item: ListItem, row: ConnectorRow): string => { - // Inactive rows sit under the "inactive · N" fold. Their state is the fold's - // job, so the kicker carries no pod and no state word. - if (row.muted) return ''; - const podId = item.connector ? connectorPodId(item.connector) : null; - // TASK-140: a not-enabled row has no connection to name a pod for, so the - // pod slot read `no pod` — which is false, it names the blocker instead. - const pod = podId ? podNameById(podId, item.connector) : t('connectors.noPod', { defaultValue: 'no pod' }); - // A row with no age (the not-enabled row's '—') carries only the pod. - return row.when === '—' ? pod : `${pod} · ${row.when}`; - }; - - const needsUser = (row: ConnectorRow): boolean => row.action !== null && !row.secondary && row.action !== 'connect'; - const selectedItem = items.find((item) => item.key === selectedKey) - || items.find((item) => needsUser(rowForItem(item))) - || items.find((item) => item.connector !== null) - || null; - - const selectItem = (item: ListItem) => { - setSelectedKey(item.key); - setConfirmRemove(null); - setExpandedGate(null); - // TASK-155: while the form is open it owns the aside, so a row click or a - // row's gear has to close it — otherwise the click looks like it did - // nothing, which is the symptom this page was reported for pointed the - // other way. `runAction` calls this first and re-opens the form for - // `connect`, so Add is unaffected. - setAddingType(null); - }; - - const runAction = (item: ListItem, action: ConnectorAction) => { - selectItem(item); - const connector = item.connector; - if (action === 'connect') { - setAddingType(itemType(item)); - return undefined; - } - // Pick a pod: the aside's gate list is already expanded for an unlinked - // row (D4), so selecting the row is the whole action. - if (action === 'pick-pod') return undefined; - if (item.kind === 'catalog') { - if (action === 'cancel') return uninstall(item.entry.installableId, item.key, 'connectors.cancelError', 'Could not cancel setup.'); - if (action === 'retry-remove') return uninstall(item.entry.installableId, item.key, 'connectors.removeError', 'Could not remove the channel.'); - if (action === 'retry') return retry(item.entry); - } - if (!connector) return undefined; - if (action === 'new-code') return regenerateCode(connector); - if (action === 'authorize') return authorizeSlack(connector, item.key); - // The row opens the aside where the active pod's lead can be chosen. - // Sending a confirm here would silently omit that choice. - if (action === 'confirm') return undefined; - return undefined; - }; - - // Live rows first; every dead row (not offered, unavailable, or not enabled) - // goes under the one shared "inactive · N" fold. - const isInactive = (item: ListItem): boolean => { - const row = rowForItem(item); - return Boolean(row.muted || row.notEnabled); - }; - const liveItems = items.filter((item) => !isInactive(item)); - const inactiveItems = items.filter(isInactive); - - const busyFor = (item: ListItem): boolean => busyId === item.key || (item.connector !== null && busyId === item.connector._id); - - const renderRow = (item: ListItem) => { - const row = rowForItem(item); - const selected = selectedItem?.key === item.key; - const label = itemLabel(item); - const busy = busyFor(item); - return ( -
- - {row.action === 'manage' && ( - // Direction A rule 2: Manage is housekeeping beside the row's word, so it is the gear. - - )} - {row.action && row.action !== 'manage' && ( - - )} - {row.notEnabled && ( - - {t('tools.ask', { defaultValue: 'Ask' })} - - )} - {rowRefusal?.key === item.key && ( -

{rowRefusal.message}

- )} -
- ); - }; - - // D4: one row per pod the user is in. The switch is the gate; the active pod - // is where the owner's typed messages land, and it is its own control. - const renderGates = (connector: Connector) => { - const gates = connector.config?.gates || {}; - const activePodId = connectorPodId(connector); - const unlinked = !activePodId; - return ( -
-

- {unlinked - ? t('connectors.pickWhere', { defaultValue: 'Pick where your messages go' }) - : t('connectors.gatesTitle', { defaultValue: 'Pods that reach this channel' })} -

- {podList.length === 0 && ( -

{t('connectors.noPods', { defaultValue: 'Join a pod first.' })}

- )} - {podList.map((pod) => { - const gate = gates[pod._id]; - const enabled = gate?.enabled === true; - const active = String(activePodId || '') === String(pod._id); - const open = unlinked || expandedGate === pod._id; - const bots = botMembers(pod); - const disabled = busyId === connector._id; - return ( -
-
- - - {enabled ? t('connectors.sinceWhen', { defaultValue: 'since {{rel}}', rel: localizeRelativeTime(gate?.since, t, { ...connectorRelativeTimeOptions, now }) }) : t('connectors.gateOff', { defaultValue: 'off' })} - - { void writeGate(connector, pod._id, { ...gate, enabled: !enabled }); }} - /> -
- {open && ( -
-
- {gateModes.map(([mode, modeLabel]) => { - const on = (gate?.mode || undefined) === mode; - return ( - - ); - })} -
- - {!active && ( - - )} -
- )} -
- ); - })} -
- ); - }; - - const renderRemove = (item: ListItem) => ( - confirmRemove === item.key ? ( - - ) : ( - - ) - ); - - // The parent's non-active states own the aside: one sentence, the verbs the - // service would honour, and nothing the owner cannot press. - const renderParentAside = (item: ListItem & { kind: 'catalog' }, row: ConnectorRow) => { - const status = item.entry.installation?.status || ''; - const busy = busyFor(item); - return ( - - ); - }; - - const renderAside = (item: ListItem) => { - const row = rowForItem(item); - if (item.kind === 'catalog') { - const status = item.entry.installation?.status; - if (!item.entry.available || !item.entry.installation) return null; - if (status !== 'active' || !item.connector || row.action === 'retry') return renderParentAside(item, row); - } - const connector = item.connector as Connector; - const isTelegram = connector.type === 'telegram'; - const isSlack = connector.type === 'slack'; - const connected = connector.status === 'connected'; - const supportsRelayControls = isTelegram || isSlack; - const mirror = connector.config?.relayAllAgentMessages === true; - const pendingBind = connector.config?.pendingBind; - const activePod = podList.find((pod) => pod._id === connectorPodId(connector)); - const availableLeads = botMembers(activePod); - const candidateSlackLead = slackLeadChoices[connector._id] - ?? connector.config?.gates?.[String(connectorPodId(connector))]?.lead ?? ''; - const selectedSlackLead = availableLeads.some((bot) => bot.username === candidateSlackLead) - ? candidateSlackLead : ''; - const userScoped = connector.scope === 'user' || Boolean(connector.config?.gates); - const busy = busyFor(item); - - return ( - - ); - }; - - const hasInstallation = (type: string): boolean => connectors.some( - (connector) => connector.type === type && Boolean(connector.installationId), - ); - const availableProviders = catalog - ? catalog.filter((entry) => entry.available && entry.offered !== false && !entry.installation).map((entry) => ({ type: entry.installableId })) - : ADD_PLATFORMS.filter((provider) => provider.enabled && !hasInstallation(provider.type)); - const renderAddForm = (aside = false) => { - const rowClass = `v2-connectors__new-row${aside ? ' v2-connectors__new-row--aside' : ''}`; - if (pods !== null && pods.length === 0) { - return ( -
-
-

{t('connectors.noPodsToConnect', { defaultValue: 'No pod yet. Create one, then come back to connect a channel.' })}

- -
-
- ); - } - return ( -
-
- {availableProviders.map((provider) => ( - - ))} -
- - -
- ); - }; - - const selectedAside = selectedItem ? renderAside(selectedItem) : null; - - return ( -
- - - {loading &&
{t('connectors.loading', { defaultValue: 'Loading connectors…' })}
} - - {!loading && ( -
-
-
- {liveItems.map(renderRow)} - - {inactiveItems.map(renderRow)} - -
- - {availableProviders.length > 0 && ( -
- -
- )} -
- {/* - TASK-155: the aside owns the add form, so the panel always changes - when a channel is being added. Before this, a selected row's detail - kept the aside and the form was rendered a second time in the left - column — clicking Add (or Connect a channel) with another row - selected left the panel on that row and put the picker somewhere - else, which reads as "Add did nothing". One form, in the panel the - page uses for the thing you are doing. - */} - {adding ? ( - - ) : selectedAside} -
- )} - - {/* Tools plan §6: the second list, under the channels, in the same grammar. */} - {!loading && } - - {(error || slackCallbackError || hostedMcpCallbackError) && ( -
- {hostedMcpCallbackError || error || slackCallbackError} -
- )} -
- ); -}; - -export default V2ConnectorsPage; diff --git a/frontend/src/v2/components/V2InactiveFold.tsx b/frontend/src/v2/components/V2InactiveFold.tsx deleted file mode 100644 index 589ce2ca8e..0000000000 --- a/frontend/src/v2/components/V2InactiveFold.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; - -type V2InactiveFoldProps = { - count: number; - children: React.ReactNode; -}; - -/** Shared collapsed roster for inactive channel and tool connection rows. */ -const V2InactiveFold: React.FC = ({ count, children }) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - if (count === 0) return null; - - return ( -
- - {open &&
{children}
} -
- ); -}; - -export default V2InactiveFold; diff --git a/frontend/src/v2/connectors/ChannelPanel.tsx b/frontend/src/v2/connectors/ChannelPanel.tsx new file mode 100644 index 0000000000..b790b622b4 --- /dev/null +++ b/frontend/src/v2/connectors/ChannelPanel.tsx @@ -0,0 +1,388 @@ +// The channel's side panel: next step while it is not connected, then relay, +// mode and the pods that reach it. Moved from the old page's aside (TASK-733); +// the verbs and copy are unchanged. +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { localizeRelativeTime } from '../utils/localizeRelativeTime'; +import { + BOT_HANDLE, + connectorPodId, + groupCode, + codeIsLive, + connectorRelativeTimeOptions, + botMembers, +} from './channelModel'; +import type { + Connector, + ConnectorAction, + ConnectorRow, + ListItem, +} from './channelModel'; +import type { ChannelConnectorsModel } from './useChannelConnectors'; + +interface Props { + m: ChannelConnectorsModel; + item: ListItem; +} + +const ChannelPanel: React.FC = ({ m, item: panelItem }) => { + const { t } = useTranslation(); + const { + pods, + busyId, + confirmRemove, + setConfirmRemove, + expandedGate, + setExpandedGate, + copied, + rowRefusal, + slackLeadChoices, + setSlackLeadChoices, + now, + podList, + gateModes, + patchConfig, + writeGate, + makeActive, + regenerateCode, + authorizeSlack, + resolveSlackBind, + remove, + retry, + copyCommand, + rowForItem, + runAction, + busyFor, + } = m; + // D4: one row per pod the user is in. The switch is the gate; the active pod + // is where the owner's typed messages land, and it is its own control. + const renderGates = (connector: Connector) => { + const gates = connector.config?.gates || {}; + const activePodId = connectorPodId(connector); + const unlinked = !activePodId; + return ( +
+

+ {unlinked + ? t('connectors.pickWhere', { defaultValue: 'Pick where your messages go' }) + : t('connectors.gatesTitle', { defaultValue: 'Pods that reach this channel' })} +

+ {podList.length === 0 && ( +

{t('connectors.noPods', { defaultValue: 'Join a pod first.' })}

+ )} + {podList.map((pod) => { + const gate = gates[pod._id]; + const enabled = gate?.enabled === true; + const active = String(activePodId || '') === String(pod._id); + const open = unlinked || expandedGate === pod._id; + const bots = botMembers(pod); + const disabled = busyId === connector._id; + return ( +
+
+ + + {enabled ? t('connectors.sinceWhen', { defaultValue: 'since {{rel}}', rel: localizeRelativeTime(gate?.since, t, { ...connectorRelativeTimeOptions, now }) }) : t('connectors.gateOff', { defaultValue: 'off' })} + + { void writeGate(connector, pod._id, { ...gate, enabled: !enabled }); }} + /> +
+ {open && ( +
+
+ {gateModes.map(([mode, modeLabel]) => { + const on = (gate?.mode || undefined) === mode; + return ( + + ); + })} +
+ + {!active && ( + + )} +
+ )} +
+ ); + })} +
+ ); + }; + + const renderRemove = (item: ListItem) => ( + confirmRemove === item.key ? ( + + ) : ( + + ) + ); + + // The parent's non-active states own the aside: one sentence, the verbs the + // service would honour, and nothing the owner cannot press. + const renderParentAside = (item: ListItem & { kind: 'catalog' }, row: ConnectorRow) => { + const status = item.entry.installation?.status || ''; + const busy = busyFor(item); + return ( + + ); + }; + + const renderAside = (item: ListItem) => { + const row = rowForItem(item); + if (item.kind === 'catalog') { + const status = item.entry.installation?.status; + if (!item.entry.available || !item.entry.installation) return null; + if (status !== 'active' || !item.connector || row.action === 'retry') return renderParentAside(item, row); + } + const connector = item.connector as Connector; + const isTelegram = connector.type === 'telegram'; + const isSlack = connector.type === 'slack'; + const connected = connector.status === 'connected'; + const supportsRelayControls = isTelegram || isSlack; + const mirror = connector.config?.relayAllAgentMessages === true; + const pendingBind = connector.config?.pendingBind; + const activePod = podList.find((pod) => pod._id === connectorPodId(connector)); + const availableLeads = botMembers(activePod); + const candidateSlackLead = slackLeadChoices[connector._id] + ?? connector.config?.gates?.[String(connectorPodId(connector))]?.lead ?? ''; + const selectedSlackLead = availableLeads.some((bot) => bot.username === candidateSlackLead) + ? candidateSlackLead : ''; + const userScoped = connector.scope === 'user' || Boolean(connector.config?.gates); + const busy = busyFor(item); + + return ( + + ); + }; + + return renderAside(panelItem); +}; + +export default ChannelPanel; diff --git a/frontend/src/v2/connectors/ConnectorDrawer.tsx b/frontend/src/v2/connectors/ConnectorDrawer.tsx new file mode 100644 index 0000000000..0b2f66ce33 --- /dev/null +++ b/frontend/src/v2/connectors/ConnectorDrawer.tsx @@ -0,0 +1,43 @@ +// The manage surface for a connected tile: a side drawer on a wide screen, a +// full-height sheet at phone width. It holds the existing panels unchanged; the +// page decides which one. +import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import ConnectorLogo from './ConnectorLogo'; + +interface Props { + type: string; + title: string; + onClose: () => void; + /** Page errors and notices: on a phone the sheet covers the page body, so they show here. */ + notices?: React.ReactNode; + children: React.ReactNode; +} + +const ConnectorDrawer: React.FC = ({ type, title, onClose, notices, children }) => { + const { t } = useTranslation(); + const ref = useRef(null); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + + useEffect(() => { ref.current?.focus(); }, [type]); + + return ( +
+
+ +

{title}

+ +
+
{notices}{children}
+
+ ); +}; + +export default ConnectorDrawer; diff --git a/frontend/src/v2/connectors/ConnectorLogo.tsx b/frontend/src/v2/connectors/ConnectorLogo.tsx new file mode 100644 index 0000000000..bc606d283c --- /dev/null +++ b/frontend/src/v2/connectors/ConnectorLogo.tsx @@ -0,0 +1,77 @@ +// The d215 artboard's connector logos: a 40px mark that carries the name, so a +// tile needs no label above it. Brand fills are the artboard's own; the two that +// follow the theme (GitHub's disc and its glyph) read the v2 tokens. +import React from 'react'; +import { PlatformGlyph } from '../icons/platforms'; + +const LOGOS: Record = { + slack: ( + <> + + + + + + ), + telegram: ( + <> + + + + ), + github: ( + <> + + + + ), + linear: ( + <> + + + + ), + atlassian: ( + <> + + + + ), + airtable: ( + <> + + + + + ), + sentry: ( + <> + + + + ), +}; + +// The connector id a catalog row uses is not always the logo's name. +const ALIASES: Record = { 'github-mcp': 'github' }; + +interface Props { + type: string; + size?: number; +} + +/** The artboard's logo when there is one; otherwise the platform glyph on a soft disc, so every tile has a mark. */ +const ConnectorLogo: React.FC = ({ type, size = 40 }) => { + const key = ALIASES[type] || type; + const mark = LOGOS[key]; + if (mark) { + return ( + + ); + } + return ( + + ); +}; + +export default ConnectorLogo; diff --git a/frontend/src/v2/connectors/ConnectorPopover.tsx b/frontend/src/v2/connectors/ConnectorPopover.tsx new file mode 100644 index 0000000000..627354efef --- /dev/null +++ b/frontend/src/v2/connectors/ConnectorPopover.tsx @@ -0,0 +1,68 @@ +// The reveal for an unconnected tile (d215): logo and name, the sentence, the tool +// names as small chips, and the act. It is anchored under its tile on a wide +// screen and is a bottom sheet at phone width, where there is no hover to reach it. +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; + +interface Props { + /** The tile the popover opens from; focus returns to it on close. */ + anchor: HTMLElement | null; + /** The positioned element the popover is placed inside. */ + container: HTMLElement | null; + label: string; + onClose: () => void; + children: React.ReactNode; +} + +const WIDTH = 300; +const GAP = 8; + +const ConnectorPopover: React.FC = ({ anchor, container, label, onClose, children }) => { + const ref = useRef(null); + const [place, setPlace] = useState<{ top: number; left: number } | null>(null); + + useLayoutEffect(() => { + if (!anchor || !container) return; + const a = anchor.getBoundingClientRect(); + const c = container.getBoundingClientRect(); + const left = Math.min(Math.max(0, a.left - c.left + a.width / 2 - WIDTH / 2), Math.max(0, c.width - WIDTH)); + setPlace({ top: a.bottom - c.top + container.scrollTop + GAP, left: left + container.scrollLeft }); + }, [anchor, container]); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === 'Escape') { event.stopPropagation(); onClose(); } + }; + const onPointer = (event: MouseEvent) => { + const target = event.target as Node; + if (ref.current?.contains(target) || anchor?.contains(target)) return; + onClose(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('mousedown', onPointer); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('mousedown', onPointer); + }; + }, [anchor, onClose]); + + // Focus moves in on open and back to the tile on close. + useEffect(() => { + const first = ref.current?.querySelector('button, a[href]'); + first?.focus(); + return () => { anchor?.focus(); }; + }, [anchor]); + + return ( +
+ {children} +
+ ); +}; + +export default ConnectorPopover; diff --git a/frontend/src/v2/connectors/ConnectorTile.tsx b/frontend/src/v2/connectors/ConnectorTile.tsx new file mode 100644 index 0000000000..eb5ee11c8f --- /dev/null +++ b/frontend/src/v2/connectors/ConnectorTile.tsx @@ -0,0 +1,69 @@ +// One tile of the connectors grid (d215 "a logo grid"). The logo carries the name; +// state is a corner mark: a status dot, a lock for admin-only, "beta" for read in +// testing. Descriptions are reveal-only, so the tile holds none. +import React from 'react'; +import ConnectorLogo from './ConnectorLogo'; + +export type TileDot = 'live' | 'idle' | 'pending' | 'attention'; + +interface Props { + type: string; + name: string; + /** What the corner dot means, in words: it is the dot's accessible name. */ + dot?: { kind: TileDot; label: string; pulse?: boolean }; + /** An admin-only connector: a lock, with the sentence as its title. */ + lock?: string; + /** A "beta" mark, with what it means as its title. */ + beta?: { word: string; title: string }; + /** Greyed: not offered here. */ + dim?: boolean; + /** The dashed "add" tile: a plus where the logo goes. */ + add?: boolean; + selected?: boolean; + expanded?: boolean; + onClick: (event: React.MouseEvent) => void; +} + +const ConnectorTile = React.forwardRef(({ + type, name, dot, lock, beta, dim, add, selected, expanded, onClick, +}, ref) => ( + +)); +ConnectorTile.displayName = 'ConnectorTile'; + +export default ConnectorTile; diff --git a/frontend/src/v2/connectors/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx new file mode 100644 index 0000000000..9ceb4b4505 --- /dev/null +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -0,0 +1,535 @@ +// The Connectors page, rebuilt from the d215 artboard (TASK-733): one logo grid for +// channels and tools. A tile's logo carries its name, state is a corner mark, +// descriptions are reveal-only, and what is not offered folds into "+N off". An +// unconnected tile reveals a popover with the act; a connected one opens a drawer +// that holds the panels the old page's aside held, verbs and copy unchanged. +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { catalogDescription } from '../utils/connectorCatalogDescription'; +import { ADD_PLATFORMS, TYPE_LABELS } from './channelModel'; +import type { ListItem } from './channelModel'; +import { entryCovers, entryIsGranted, isDead } from './toolModel'; +import type { ToolCatalogEntry } from './toolModel'; +import { useChannelConnectors } from './useChannelConnectors'; +import { useToolConnectors } from './useToolConnectors'; +import { useToolPanels } from './ToolPanels'; +import ChannelPanel from './ChannelPanel'; +import ConnectorDrawer from './ConnectorDrawer'; +import ConnectorLogo from './ConnectorLogo'; +import ConnectorPopover from './ConnectorPopover'; +import ConnectorTile from './ConnectorTile'; +import type { TileDot } from './ConnectorTile'; +import './connectors.css'; + +type Filter = 'all' | 'channels' | 'tools'; + +interface Pop { + key: string; + anchor: HTMLElement; +} + +const ASK_HREF = 'https://github.com/Team-Commonly/commonly/issues/new?title=Connector%20request'; +const MAX_CHIPS = 3; +const FILTERS: Filter[] = ['all', 'channels', 'tools']; +// A brand name, not translatable copy. +const GITHUB_BRAND = 'GitHub'; + +const SearchGlyph = () => ( + +); + +const ConnectorsPage: React.FC = () => { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const c = useChannelConnectors(); + const m = useToolConnectors(c.podList, c.hostedMcpManualRevoke); + const tp = useToolPanels(m); + const [filter, setFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [pop, setPop] = useState(null); + const [toolKey, setToolKey] = useState(null); + const [offOpen, setOffOpen] = useState({ channels: false, tools: false }); + const [container, setContainer] = useState(null); + const tileRefs = useRef>({}); + + const q = search.trim().toLowerCase(); + const { setQuery } = m; + const onSearch = (value: string) => { + setSearch(value); + setQuery(value); + }; + + // ---- channels ---- + const channelItems = useMemo(() => c.items.filter((item) => { + if (!q) return true; + return (c.itemLabel(item) || '').toLowerCase().includes(q); + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [c.items, q]); + const channelInactive = (item: ListItem): boolean => { + const row = c.rowForItem(item); + return Boolean(row.muted || row.notEnabled); + }; + const liveChannels = channelItems.filter((item) => !channelInactive(item)); + const offChannels = channelItems.filter(channelInactive); + + const hasInstallation = (type: string): boolean => c.connectors.some( + (connector) => connector.type === type && Boolean(connector.installationId), + ); + const availableProviders = c.catalog + ? c.catalog.filter((entry) => entry.available && entry.offered !== false && !entry.installation).map((entry) => ({ type: entry.installableId })) + : ADD_PLATFORMS.filter((provider) => provider.enabled && !hasInstallation(provider.type)); + + // ---- tools ---- + const grants = m.grants || []; + const toolEntries = m.catalog.filter((entry) => ( + !q || entry.label.toLowerCase().includes(q) || entry.tools.some((tool) => tool.name.toLowerCase().includes(q)) + )); + const toolGranted = (entry: ToolCatalogEntry): boolean => entryIsGranted(entry, grants, m.now); + const grantsOf = (entry: ToolCatalogEntry) => grants.filter((grant) => entryCovers(entry, grant)); + const hiddenOf = (entry: ToolCatalogEntry) => m.hiddenConnections.filter((connection) => connection.entryId === entry.entryId); + const toolOff = (entry: ToolCatalogEntry): boolean => !entry.available && !toolGranted(entry); + const liveTools = toolEntries.filter((entry) => !toolOff(entry)); + const offTools = toolEntries.filter(toolOff); + const orphanHidden = m.hiddenConnections.filter((connection) => ( + !m.catalog.some((entry) => entry.entryId === connection.entryId) + && (!q || connection.label.toLowerCase().includes(q)) + )); + // A grant whose catalogue entry is gone (retired or unreadable) has no tile, but it + // is still live access: it stays listed so it can still be seen and revoked. + const orphanGrants = grants.filter((grant) => ( + !m.catalog.some((entry) => entryCovers(entry, grant)) + && (!q || m.toolLabel(grant).toLowerCase().includes(q)) + )); + const toolHasDrawer = (entry: ToolCatalogEntry): boolean => grantsOf(entry).length > 0 || hiddenOf(entry).length > 0; + + // ---- what is open ---- + const drawerItem = c.items.find((item) => item.key === c.selectedKey) || null; + const channelDrawerOpen = c.adding || drawerItem !== null; + const toolEntry = toolKey ? m.catalog.find((entry) => entry.installableId === toolKey) || null : null; + const toolDrawerOpen = !channelDrawerOpen && Boolean(m.draft || m.githubAppSetup || toolEntry || m.selected); + + // A drawer and a reveal never show together, and a reveal that opened a drawer + // stays closed when the drawer does. + useEffect(() => { + if (channelDrawerOpen || toolDrawerOpen) setPop(null); + }, [channelDrawerOpen, toolDrawerOpen]); + + // Coming back from a hosted-MCP disconnect that needs a manual revoke, the notice + // lives in its tile's reveal or drawer: open that tile once so it is not missed. + const revokeNotice = c.hostedMcpManualRevoke; + const revokeEntry = revokeNotice + ? m.catalog.find((entry) => entry.entryId === revokeNotice.entryId + && entry.connections.some((connection) => connection.connectionId === revokeNotice.connectionId)) + : null; + const revealedRevoke = useRef(false); + useEffect(() => { + if (!revokeEntry || revealedRevoke.current) return; + const anchor = tileRefs.current[`tool:${revokeEntry.installableId}`]; + if (!anchor) return; + revealedRevoke.current = true; + if (toolHasDrawer(revokeEntry)) setToolKey(revokeEntry.installableId); + else setPop({ key: `tool:${revokeEntry.installableId}`, anchor }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [revokeEntry?.installableId, container]); + + const closeTools = useCallback(() => { + setToolKey(null); + m.setSelectedId(null); + m.setDraft(null); + m.setGithubAppSetup(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const closeChannels = useCallback(() => { + c.setSelectedKey(null); + c.setAddingType(null); + c.setConfirmRemove(null); + c.setExpandedGate(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const closePop = useCallback(() => setPop(null), []); + + const openChannel = (item: ListItem, event: React.MouseEvent) => { + closeTools(); + // An installed provider this instance no longer offers has no panel: it gets the reveal (the reason and Ask). + const hasConnection = item.connector !== null || (item.kind === 'catalog' && item.entry.available && Boolean(item.entry.installation)); + if (hasConnection) { + setPop(null); + c.selectItem(item); + return; + } + closeChannels(); + setPop({ key: item.key, anchor: event.currentTarget }); + }; + + const openTool = (entry: ToolCatalogEntry, event: React.MouseEvent) => { + closeChannels(); + m.setDraft(null); + m.setGithubAppSetup(null); + m.setSelectedId(null); + if (toolHasDrawer(entry)) { + setPop(null); + setToolKey(entry.installableId); + return; + } + setToolKey(null); + setPop({ key: `tool:${entry.installableId}`, anchor: event.currentTarget }); + }; + + const openAdd = () => { + closeTools(); + setPop(null); + c.setSelectedKey(null); + c.setAddingType(availableProviders[0]?.type || null); + }; + + // ---- tile builders ---- + const channelDot = (item: ListItem): { kind: TileDot; label: string; pulse?: boolean } | undefined => { + const row = c.rowForItem(item); + if (row.muted || row.notEnabled) return undefined; + const attention = c.needsUser(row) && row.dot !== 'live'; + if (attention) return { kind: 'attention', label: row.line, pulse: row.pulse }; + if (row.dot === 'live') return { kind: 'live', label: row.line, pulse: row.pulse }; + if (row.dot === 'pending') return { kind: 'pending', label: row.line, pulse: row.pulse }; + if (row.dot === 'idle') return { kind: 'idle', label: row.line }; + return undefined; + }; + + const renderChannelTile = (item: ListItem, dim = false) => { + const label = c.itemLabel(item) || ''; + return ( + { tileRefs.current[item.key] = node; }} + type={c.itemType(item)} + name={label} + dot={channelDot(item)} + dim={dim} + selected={drawerItem?.key === item.key} + expanded={pop?.key === item.key} + onClick={(event) => openChannel(item, event)} + /> + ); + }; + + const toolDot = (entry: ToolCatalogEntry): { kind: TileDot; label: string } | undefined => ( + toolGranted(entry) + ? { kind: 'live', label: t('tools.granted', { defaultValue: 'Granted' }) } + : undefined + ); + + const renderToolTile = (entry: ToolCatalogEntry, dim = false) => ( + { tileRefs.current[`tool:${entry.installableId}`] = node; }} + type={entry.installableId} + name={entry.label} + dot={toolDot(entry)} + dim={dim} + lock={entry.installableId === 'github' && entry.connections.length === 0 && !toolGranted(entry) + ? t('tools.adminOnce', { defaultValue: 'An admin installs this once' }) + : undefined} + beta={entry.entryId === 'atlassian' + ? { word: t('tools.beta', { defaultValue: 'beta' }), title: t('tools.readInTesting', { defaultValue: 'read in testing' }) } + : undefined} + selected={toolEntry?.installableId === entry.installableId} + expanded={pop?.key === `tool:${entry.installableId}`} + onClick={(event) => openTool(entry, event)} + /> + ); + + // ---- the reveal ---- + const renderPopover = () => { + if (!pop) return null; + const channel = c.items.find((item) => item.key === pop.key); + if (channel) { + const row = c.rowForItem(channel); + const label = c.itemLabel(channel) || ''; + const description = channel.kind === 'catalog' + ? catalogDescription(i18n, channel.entry.installableId, channel.entry.description) + : row.line; + return ( + +
{label}
+ {description &&

{description}

} + {(row.muted || row.notEnabled) && row.line &&

{row.line}

} + {row.detail &&

{row.detail}

} +
+ {row.action === 'connect' && ( + + )} + {row.notEnabled && ( + + {t('tools.ask', { defaultValue: 'Ask' })} + + )} +
+
+ ); + } + const entry = m.catalog.find((candidate) => `tool:${candidate.installableId}` === pop.key); + if (!entry) return null; + const names = entry.tools.map((tool) => tool.name); + return ( + +
+ + {entry.label} + {entry.entryId === 'atlassian' && ( + {t('tools.beta', { defaultValue: 'beta' })} + )} +
+

{catalogDescription(i18n, entry.installableId, entry.description)}

+ {names.length > 0 && ( +
+ {names.slice(0, MAX_CHIPS).map((name) => {name})} + {names.length > MAX_CHIPS && +{names.length - MAX_CHIPS}} +
+ )} + {tp.renderEntryNote(entry)} +
{tp.renderEntryActions(entry)}
+
+ ); + }; + + // ---- the drawer ---- + const renderAddForm = () => { + if (c.pods !== null && c.pods.length === 0) { + return ( +
+

{t('connectors.noPodsToConnect', { defaultValue: 'No pod yet. Create one, then come back to connect a channel.' })}

+ +
+ ); + } + return ( +
+
+ {availableProviders.map((provider) => ( + + ))} +
+ + +
+ ); + }; + + const renderChannelDrawer = () => { + if (c.adding) { + const title = t('connectors.connectChannel', { defaultValue: 'Connect a channel' }); + return ( + +
+

{t('connectors.nextStep', { defaultValue: 'Next step' })}

+ {renderAddForm()} +
+
+ ); + } + if (!drawerItem) return null; + return ( + + + + ); + }; + + const renderToolDrawer = () => { + if (m.draft) { + return ( + + {tp.renderDraft()} + + ); + } + if (m.githubAppSetup) { + return ( + + {tp.renderGithubAppSetup()} + + ); + } + if (!toolEntry) { + if (!m.selected) return null; + return ( + + {tp.renderAside(m.selected)} + + ); + } + const entryGrants = grantsOf(toolEntry); + const liveGrants = entryGrants.filter((grant) => !isDead(grant, m.now)); + const deadGrants = entryGrants.filter((grant) => isDead(grant, m.now)); + return ( + + {!toolGranted(toolEntry) && ( +
+

{catalogDescription(i18n, toolEntry.installableId, toolEntry.description)}

+ {tp.renderEntryNote(toolEntry)} +
{tp.renderEntryActions(toolEntry)}
+
+ )} +
+ {liveGrants.map(tp.renderRow)} + {deadGrants.length > 0 && ( + <> +

{t('connectors.inactiveFold', { defaultValue: 'inactive · {{n}}', n: deadGrants.length })}

+ {deadGrants.map(tp.renderRow)} + + )} + {hiddenOf(toolEntry).map(tp.renderHiddenConnection)} +
+ {m.selected && tp.renderAside(m.selected)} +
+ ); + }; + + // ---- the page ---- + const connectedChannels = c.connectedCount; + const grantedTools = m.catalog.filter(toolGranted).length; + const onCount = connectedChannels + grantedTools; + // Counted over everything, not the search result, so the header holds still while typing. + const offeredCount = c.items.filter((item) => !channelInactive(item)).length + + m.catalog.filter((entry) => !toolOff(entry)).length; + const availableCount = offeredCount - onCount; + const meta = t('connectors.meta', { defaultValue: '{{on}} on · {{available}} available', on: onCount, available: Math.max(0, availableCount) }); + + const showChannels = filter !== 'tools'; + const showTools = filter !== 'channels'; + const errorText = c.hostedMcpCallbackError || c.error || c.slackCallbackError || ( + !m.draft && !m.selected && !m.githubAppSetup ? m.error : null + ); + const noticeBlock = ( + <> + {errorText &&

{errorText}

} + {m.removedHiddenNotice && ( +

+ {m.removedHiddenNotice.revokePage + ? <>{t('tools.removedReviewProvider', { defaultValue: 'Connection removed. Review its access at {{label}}.', label: m.removedHiddenNotice.label })} {t('tools.openProviderSettings', { defaultValue: 'Open provider settings' })} + : t('tools.disconnected', { defaultValue: 'Disconnected {{label}}.', label: m.removedHiddenNotice.label })} +

+ )} + + ); + const nothing = !c.loading && q && liveChannels.length + offChannels.length + toolEntries.length + orphanHidden.length + orphanGrants.length === 0; + + const offToggle = (group: 'channels' | 'tools', count: number) => (count > 0 ? ( + + ) : null); + + return ( +
+
+

{t('connectors.title', { defaultValue: 'Connectors' })}

+ {!c.loading && {meta}} + + +
+ {FILTERS.map((value) => ( + + ))} +
+
+ + {c.loading &&
{t('connectors.loading', { defaultValue: 'Loading connectors…' })}
} + + {!c.loading && ( +
+
+ {!channelDrawerOpen && !toolDrawerOpen && noticeBlock} + + {showChannels && ( +
+

+ {t('connectors.channelsGroup', { defaultValue: 'Channels' })} + {offToggle('channels', offChannels.length)} +

+
+ {liveChannels.map((item) => renderChannelTile(item))} + {offOpen.channels && offChannels.map((item) => renderChannelTile(item, true))} + {availableProviders.length > 0 && !q && ( + + )} +
+
+ )} + + {showTools && ( +
+

+ {t('tools.title', { defaultValue: 'Tools' })} + {offToggle('tools', offTools.length + orphanHidden.length)} +

+
+ {liveTools.map((entry) => renderToolTile(entry))} + {offOpen.tools && offTools.map((entry) => renderToolTile(entry, true))} +
+ {orphanGrants.length > 0 &&
{orphanGrants.map(tp.renderRow)}
} + {offOpen.tools && orphanHidden.length > 0 && ( +
{orphanHidden.map(tp.renderHiddenConnection)}
+ )} +
+ )} + + {nothing &&

{t('tools.nothingMatches', { defaultValue: 'Nothing matches.' })}

} +
+ {!channelDrawerOpen && !toolDrawerOpen && renderPopover()} + {renderChannelDrawer() || (toolDrawerOpen ? renderToolDrawer() : null)} +
+ )} +
+ ); +}; + +export default ConnectorsPage; diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx new file mode 100644 index 0000000000..4c0050ce9b --- /dev/null +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -0,0 +1,613 @@ +// The Tools panels: a grant's details and trail, the Add and Change access form, +// the GitHub App setup, and the connection notices. Moved from the old Tools list +// (TASK-733); verbs and copy are unchanged. The rows that opened them are gone. +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { localizeRelativeTime, localizeWindow } from '../utils/localizeRelativeTime'; +import { ActGlyph } from '../icons/glyphs'; +import { PlatformGlyph } from '../icons/platforms'; +import { catalogDescription } from '../utils/connectorCatalogDescription'; +import { + shortAge, + isExpired, + isDead, + PER_ENTRY_REVOKE_COPY, +} from './toolModel'; +import type { + GrantWriteMode, + ToolGrant, + ToolOutcome, + ToolCatalogEntry, + HiddenToolConnection, +} from './toolModel'; +import type { ToolConnectorsModel } from './useToolConnectors'; + +const G: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + +); +/** Outcome marks: a category, so a glyph; the word stays in the log line and the title. + * + * The `superseded` mark is a shield with a SHORT BAR, not the shield-and-check it + * first shipped with: the resolution it stands for may be an + * approval, a decline or an expiry, and the row beside it carries the verdict — + * a check read as "approved" next to a sibling that says `refused`. The bar says + * settled without saying how. */ +const OutcomeGlyph: React.FC<{ outcome: ToolOutcome }> = ({ outcome }) => { + if (outcome === 'ok') return ; + if (outcome === 'refused') return ; + if (outcome === 'pending_approval') return ; + if (outcome === 'superseded') return ; + return ; +}; +/** Mode marks: eye for read, pen for write, shield for write that asks first. */ +const ModeGlyph: React.FC<{ mode: GrantWriteMode }> = ({ mode }) => { + if (mode === 'read') return ; + if (mode === 'write') return ; + return ; +}; + + +export const useToolPanels = (m: ToolConnectorsModel) => { + const { t, i18n } = useTranslation(); + const { + manualRevokeNotice, + isGranter, + seats, + trail, + trailError, + selectedId, + setSelectedId, + draft, + setDraft, + githubAppSetup, + setGithubAppSetup, + staleAfterChange, + confirmRevoke, + setConfirmRevoke, + confirmDisconnect, + setConfirmDisconnect, + confirmHiddenRemoval, + setConfirmHiddenRemoval, + busy, + error, + joinPolicyFixPodId, + policyFixedPodId, + grants, + now, + isAdmin, + podIds, + entryFor, + noticeMatchesEntry, + toolLabel, + podName, + memberName, + seatLabel, + grantPodId, + joinList, + audienceLabels, + irreversibleTools, + asksFirst, + outcomeLabel, + modeLabel, + entryGrantModes, + entryModeCopy, + usedRecently, + openDraft, + openGithubAppSetup, + draftEntry, + draftTools, + selectedAgentIds, + submitDraft, + fixJoinPolicy, + connectHostedMcp, + submitGithubAppSetup, + retryStaleRevoke, + revoke, + removeHiddenConnection, + disconnect, + } = m; + const renderManualRevokeNotice = (entry: ToolCatalogEntry | null) => { + if (!manualRevokeNotice || !noticeMatchesEntry(entry)) return null; + // Per-entry copy names only what that vendor's page lists; entries without it fall back to the generic line. + const context = PER_ENTRY_REVOKE_COPY.has(manualRevokeNotice.entryId) ? manualRevokeNotice.entryId : undefined; + return ( +

+ {t('connectors.hostedMcpManualRevoke', { + defaultValue: 'Your previous {{provider}} account may still have Commonly connected. Switch to it on {{provider}}, then remove Commonly there.', + context, + provider: manualRevokeNotice.provider, + })}{' '} + + {t('connectors.hostedMcpReviewAuthorization', { + defaultValue: "Open {{provider}}'s connected apps", + context, + provider: manualRevokeNotice.provider, + })} + +

+ ); + }; + // Hosted connections only: a GitHub App connection is the instance's, not the viewer's to drop. + const renderDisconnect = (entry: ToolCatalogEntry | null, variant: 'row' | 'aside') => { + // One connection only: a grant does not say which connection it was minted on, + // so with several the button could drop the wrong one. + const connection = entry && entry.connectionType === 'hosted-mcp' && entry.connections.length === 1 ? entry.connections[0] : null; + if (!entry || !connection) return null; + const cls = variant === 'row' + ? 'v2-connector-row__action v2-connector-row__action--secondary' + : 'v2-connector-aside__secondary'; + if (confirmDisconnect === connection.connectionId) { + return ( + <> + + + + ); + } + return ( + + ); + }; + + const renderHiddenConnection = (connection: HiddenToolConnection) => ( +
+ + + + {t('tools.existingConnection', { defaultValue: 'existing connection' })} + {t('tools.noLongerOffered', { defaultValue: 'This connector is no longer offered here.' })} + {connection.revokePage && {t('tools.reviewProviderAccess', { defaultValue: 'Review access at {{label}}', label: connection.label })}} + + {confirmHiddenRemoval === connection.connectionId ? ( + <> + + + + ) : ( + + )} +
+ ); + + const renderRow = (grant: ToolGrant) => { + const dead = isDead(grant, now); + const podId = grantPodId(grant); + const granter = memberName(grant.grantedBy); + const isSelected = selectedId === grant.grantId; + const entry = entryFor(grant); + const label = toolLabel(grant); + const noticeGrantId = noticeMatchesEntry(entry) + ? (grants || []).find((candidate) => ( + !isDead(candidate, now) && entryFor(candidate)?.installableId === entry?.installableId + ))?.grantId + : null; + // What the grant was given TO, which is a seat when the target is a seat. It + // is not the row's location: the kicker and the accessible name both name the + // pod the row lives in, so a seat grant under Ops has a sentence reading + // `granted to Reed` in a row announced as `View GitHub in Ops`. Sharing one + // label between the two put the seat's name where the location belongs + // (TASK-179, ux-lead's gate at 07873c19) — two nouns, so two expressions. + const targetLabel = grant.target.kind === 'pod' ? podName(grant.target.id) : seatLabel(podId, grant.target.id); + // Direction A rule 3: `pod · verb age` — the verb from a key, the age from the timestamp. + const when = t('tools.grantedAge', { defaultValue: 'granted {{age}}', age: shortAge(grant.createdAt, now, t) }); + const kicker = `${podId ? podName(podId) : seatLabel(null, grant.target.id)} · ${when}`; + const revokedBy = grant.revokedBy ? memberName(grant.revokedBy) : null; + const line2 = dead + ? (grant.revokedAt + ? (revokedBy + ? t('tools.revokedByLine', { defaultValue: 'revoked by {{member}} {{rel}}', member: revokedBy, rel: localizeRelativeTime(grant.revokedAt, t, { now, pastEvent: true }) }) + : t('tools.revokedLine', { defaultValue: 'revoked {{rel}}', rel: localizeRelativeTime(grant.revokedAt, t, { now, pastEvent: true }) })) + : t('tools.expiredLine', { defaultValue: 'expired {{rel}}', rel: localizeRelativeTime(grant.expiresAt, t, { now }) })) + // Direction A rule 1: the write mode is the glyph beside this line; its words ride the 390 kicker. + // One interpolated key, not `{{agents}}` + a separate 'may use it': a language + // that orders the clause differently needs the whole sentence (TASK-164). The + // rendered English is unchanged. + // An empty audience is its own sentence, not the interpolated one with an empty + // subject: zh writes 「没有智能体可以使用」 with no space, which `{{agents}} 可以使用` + // cannot produce (TASK-179). + : (grant.effectiveAudience.length === 0 + ? t('tools.nobodyMayUse', { defaultValue: 'nobody may use it' }) + : t('tools.mayUse', { defaultValue: '{{agents}} may use it', agents: audienceLabels(grant) })); + return ( +
+ + {dead && entry && isGranter(grant) ? ( + // Grant again is the granter's too: the mint 403s anyone but the Connection's owner. + + ) : ( + // Direction A rule 2: Manage is housekeeping beside the row's word, so it is the gear. + + )} + {noticeGrantId === grant.grantId && renderManualRevokeNotice(entry)} +
+ ); + }; + + // Where the person who connected a hosted entry picks what it reaches (for + // GitHub, the App's install page). Shown on the offer and on the details of + // their live grant, since a grant takes the entry off the offer list. + const renderAccessLink = (entry: ToolCatalogEntry) => ( + + {t('tools.chooseAccess', { defaultValue: 'Choose what {{label}} can reach', label: entry.label })} + + ); + + // What a person can do with an entry that has no live grant: the verbs of the + // old "Not yet" row, shared by the tile's popover. + const renderEntryActions = (entry: ToolCatalogEntry) => { + const canAdd = entry.available && entry.connections.length > 0 && podIds.length > 0; + const canInstallGithubApp = isAdmin && entry.installableId === 'github' && entry.available && entry.connections.length === 0; + // A hosted entry is connected by the person who will grant it (§1: member + // mode), so the action belongs on the tile and needs no admin. + const isHosted = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId); + const canConnectHosted = Boolean(isHosted) && entry.available && entry.connections.length === 0; + return ( + <> + {!entry.available && ( + + {t('tools.ask', { defaultValue: 'Ask' })} + + )} + {canConnectHosted && ( + + )} + {canAdd && ( + + )} + {renderDisconnect(entry, 'row')} + {canInstallGithubApp && ( + + )} + + ); + }; + + // The one line under an entry's name that says what is left to do before it + // works: connect your own account, ask an admin, or the modes it grants. + const renderEntryNote = (entry: ToolCatalogEntry) => { + const isHosted = entry.connectionType === 'hosted-mcp' && Boolean(entry.entryId); + const canConnectHosted = Boolean(isHosted) && entry.available && entry.connections.length === 0; + return ( + <> + {entry.available && ( +

+ {entry.connections.length === 0 + ? (isHosted + ? t('tools.connectOwn', { defaultValue: 'connect your own {{label}} account', label: entry.label }) + : t('tools.noConnection', { defaultValue: 'install the GitHub App first · an admin does this once' })) + : entryModeCopy(entry)} +

+ )} + {canConnectHosted && entry.connectNotice &&

{entry.connectNotice}

} + {isHosted && entry.available && entry.accessPage &&

{renderAccessLink(entry)}

} + {renderManualRevokeNotice(entry)} + + ); + }; + + const renderGithubAppSetup = () => { + if (!githubAppSetup) return null; + return ( + + ); + }; + + const renderDraft = () => { + if (!draft || !draftEntry) return null; + const podSeats = seats[draft.podId] || []; + const tools = draftTools(draftEntry, draft.writeMode); + const irreversible = irreversibleTools(draftEntry, tools); + const asks = draft.writeMode === 'read' + ? t('tools.asksNothing', { defaultValue: 'nothing asks first' }) + : draft.writeMode === 'write-with-confirm' + ? t('tools.asksEveryWrite', { defaultValue: 'every write asks first' }) + : (irreversible.length ? t('tools.asksList', { defaultValue: '{{tools}} ask first', tools: joinList(irreversible) }) : t('tools.asksNothing', { defaultValue: 'nothing asks first' })); + return ( + + ); + }; + + const renderAside = (grant: ToolGrant) => { + const dead = isDead(grant, now); + const podId = grantPodId(grant); + const granter = memberName(grant.grantedBy); + const revokedBy = memberName(grant.revokedBy); + const entry = entryFor(grant); + const counts = trail?.counts; + return ( + + ); + }; + + return { renderManualRevokeNotice, renderDisconnect, renderHiddenConnection, renderRow, renderAccessLink, renderEntryActions, renderEntryNote, renderGithubAppSetup, renderDraft, renderAside }; +}; diff --git a/frontend/src/v2/connectors/channelModel.ts b/frontend/src/v2/connectors/channelModel.ts new file mode 100644 index 0000000000..66638aa54e --- /dev/null +++ b/frontend/src/v2/connectors/channelModel.ts @@ -0,0 +1,274 @@ +// Channel connectors: the shapes the integrations and catalog routes return, and +// the pure helpers the channel hook and panels share. Moved whole from the +// retired channels page (TASK-733); nothing here renders. +import { V2Pod, V2PodMember } from '../hooks/useV2Pods'; +import { MarkName } from '../icons/glyphs'; + +export interface ConnectorGate { + enabled?: boolean; + mode?: 'attention' | 'mirror'; + lead?: string; + since?: string; +} + +export interface ConnectorConfig { + entryId?: string; + revokePage?: string; + chatTitle?: string; + // A linked Slack stores its workspace name here (slackOAuthService), never in + // chatTitle — so without this the row read "Slack · linked to …" beside the + // word Slack (TASK-156). + teamName?: string; + connectCode?: string; + connectCodeExpiresAt?: string; + liveRelay?: boolean; + relayAllAgentMessages?: boolean; + leadAgentUsername?: string; + gates?: Record; + // Projected by the server as { reason, at } only — the admin is never named. + adminPause?: { reason?: string; at?: string }; + pendingBind?: { + teamName?: string; + slackUserName?: string; + expiresAt?: string; + }; +} + +export interface Connector { + _id: string; + installationId?: string; + type: string; + status: string; + // A reason this connector needs attention. Two writers share the field: our + // own classifier, and externalFeedService, which copies a provider's error + // text or a raw `err.message` in here. `errorMessageUserFacing` is what tells + // them apart, and only a message written for a person is rendered — the field + // holding a value is not permission to show it. + errorMessage?: string | null; + errorMessageUserFacing?: boolean; + scope?: 'user' | 'pod'; + isActive?: boolean; + createdAt?: string; + updatedAt?: string; + config?: ConnectorConfig; + podId?: { _id: string; name?: string } | string | null; +} + +export interface CatalogInstallation { + status: string; + // The same pair the pod-scoped half reads, and the same rule: the field + // holding a value is not permission to show it. A catalog failure is + // `markProjectionFailure`'s raw exception message unless a writer says + // otherwise, so the flag decides and the generic sentence is the fallback + // (TASK-131). + errorMessage?: string; + errorMessageUserFacing?: boolean; + boundPodId?: string; + claimedAt?: string; + updatedAt?: string; + components?: { name?: string; status?: string; errorMessage?: string }[]; +} + +export interface CatalogEntry { + installableId: string; + // Two lists (tools plan, option A): this page draws channels; the + // Tools page draws tool Installables, so a `tools` row never renders here. + list?: 'channels' | 'tools'; + connectionType?: 'github-app' | 'hosted-mcp'; + label?: string; + description?: string; + available: boolean; + // Capability vs offerability. `available` says this + // instance holds the credentials; `offered` says a builtin Installable row + // exists to install. Only both together may show an Add. Absent is read as + // offered: the field's only producer always emits it, so a missing value + // means a server older than this page, and defaulting the other way would + // blank every channel for the length of a rolling update instead of briefly + // restoring what this page did before the field existed. + offered?: boolean; + unavailableReason?: string; + // Tool entries provide the provider name for the hosted connection row. + entryId?: string; + installation: CatalogInstallation | null; + integration: Connector | null; +} + +export interface CatalogResponse { + installables?: CatalogEntry[]; +} + +export interface InstallResponse { + status?: 'active' | 'installing' | 'activating' | 'uninstalling'; + boundPodId?: string; +} + +export interface InstallErrorResponse { + code?: string; + error?: string; + boundPodId?: string; +} + +export interface SlackAuthorizeResponse { + authorizeUrl?: string; +} + +export type ConnectorAction = + | 'manage' | 'show-code' | 'new-code' | 'authorize' | 'confirm' + | 'connect' | 'cancel' | 'retry' | 'retry-remove' | 'pick-pod'; + +export interface ConnectorRow { + action: ConnectorAction | null; + actionLabel?: string; + detail: string; + dot: 'live' | 'idle' | 'pending' | 'empty' | 'not-yet'; + line: string; + // Direction A: relay mode is a category, so it is a mark on the row. `label` + // is the sentence (title + aria-label); `word` is what the 390 kicker shows, + // where tooltips do not exist. + mark?: { name: MarkName; label: string; word: string }; + muted?: boolean; + notEnabled?: boolean; + // TASK-024: a row whose provider is built but not offered here. It shares + // `notEnabled`'s row class and Ask link, and must not share its kicker — + // "not enabled" claims a missing credential this state does not have. + notConnectable?: boolean; + pulse: boolean; + secondary?: boolean; + when: string; +} + +// One list, two sources: the catalog keys a row per provider the instance +// knows about; legacy pod-scoped rows (no installation) render as before. +export type ListItem = + | { kind: 'catalog'; key: string; entry: CatalogEntry; connector: Connector | null } + | { kind: 'legacy'; key: string; connector: Connector }; + +export const TYPE_LABELS: Record = { + telegram: 'Telegram', + discord: 'Discord', + slack: 'Slack', + whatsapp: 'WhatsApp', + messenger: 'Messenger', + groupme: 'GroupMe', + x: 'X', + instagram: 'Instagram', + // Human-readable fallback only. These tool connections are still excluded + // from Channels by type in allItems below. + 'github-app': 'GitHub App', + 'hosted-mcp': 'Hosted tool', +}; + +export const TOOL_ONLY_CONNECTION_TYPES = new Set(['github-app', 'hosted-mcp']); + +export const mappedTypeLabel = (type: string): string | null => TYPE_LABELS[type] || null; + +// The onboarding sentence for a provider you can connect right now. Keyed by +// provider and deliberately without a default: an unmapped provider renders no +// detail line at all. The two-branch ternary this replaces gave every provider +// that was not telegram Slack's "one click in your workspace", so a stranger +// reading a Discord row was told to click something in a workspace they may not +// have. +export const CONNECT_DETAIL: Record = { + telegram: { key: 'connectors.availableTelegram', defaultValue: 'one message' }, + slack: { key: 'connectors.availableSlack', defaultValue: 'one click in your workspace' }, +}; + +// Fallback only: when the catalog cannot be read the page still offers the +// two providers it can drive, exactly as it did before the catalog existed. +export const ADD_PLATFORMS = [ + { type: 'telegram', enabled: true }, + { type: 'slack', enabled: true }, +]; +// Providers we have not built at all. This is NOT the same claim as either +// state the catalog can report: "not enabled on this instance" is a missing +// credential, and "not connectable yet" is a provider we have built and this +// instance cannot offer because no builtin Installable row carries it. Both of +// those arrive through the catalog, with their own state line, so this list +// holds only the providers that have no manifest to arrive through. Discord +// used to sit here, which made the page say "we don't build this" about a +// shipping connector (routes/discord.ts) whose only defect was a missing +// readiness() declaration; it now renders the roster state line instead. +export const UNAVAILABLE_PLATFORM_LABELS = ['WhatsApp']; + +export const BOT_HANDLE = process.env.REACT_APP_TELEGRAM_BOT_HANDLE || ''; +export const RECENT_MS = 10 * 60_000; +// Mirrors INSTALL_LOCK_TTL_MS on the server: Cancel appears exactly when the +// lifecycle verb would honour it (a stale claim), never before. +export const INSTALL_LOCK_TTL_MS = 60_000; +export const TRANSIENT_STATUSES = ['installing', 'activating', 'uninstalling']; + +export const installableLifecyclePath = (type: string): string => ( + `/api/installables/${encodeURIComponent(type)}/install` +); + +export const installErrorResponse = (error: unknown): { status?: number; data?: InstallErrorResponse } => ( + (error as { response?: { status?: number; data?: InstallErrorResponse } })?.response || {} +); + +// Row C: the Slack authorize and bind verbs refuse with a `code` the server +// already sends (`slackError` in backend/routes/installables.ts), so the row +// can say which refusal it was rather than one generic apology. An unlisted +// code falls back to the server's own sentence, then to the generic copy. +export const SLACK_REFUSALS: Record = { + slack_already_authorized: { key: 'connectors.slackAlreadyAuthorized', defaultValue: 'Slack is already awaiting confirmation or connected.' }, + slack_authorization_unavailable: { key: 'connectors.slackAuthorizationUnavailable', defaultValue: 'Slack authorization is no longer available.' }, + slack_installation_not_found: { key: 'connectors.slackInstallationNotFound', defaultValue: 'Slack installation not found.' }, + slack_bind_missing: { key: 'connectors.slackBindMissing', defaultValue: 'There is no Slack authorization to confirm.' }, + slack_bind_expired: { key: 'connectors.slackBindExpired', defaultValue: 'Slack authorization expired. Start again.' }, + slack_lead_invalid: { key: 'connectors.slackLeadInvalid', defaultValue: 'Choose an agent in the active pod.' }, + slack_pod_access_denied: { key: 'connectors.slackPodAccessDenied', defaultValue: 'You no longer have access to this pod.' }, +}; + +export const connectorPodId = (connector: Connector | null | undefined): string | null => { + if (!connector || !connector.podId) return null; + return typeof connector.podId === 'object' ? String(connector.podId._id || '') || null : String(connector.podId); +}; + +export const groupCode = (code: string): string => (code.match(/.{1,4}/g) || [code]).join(' '); + +// Mirrors `PERSONAL_POD_TYPES` in `backend/services/podTypePolicyService.ts` — +// private conversation surfaces, not rooms a channel should join by default. +// `/api/pods` returns the caller's own 1:1 room first, so TASK-155 shipped with +// the picker defaulting to "Scout (Default)", an agent-room. The pods stay in +// the list (a DM is a legal target) — they just never become the default. +export const PERSONAL_POD_TYPES = new Set(['agent-admin', 'agent-room', 'agent-dm']); + +export const defaultPodForChannel = (pods: V2Pod[]): string => ( + (pods.find((pod) => !PERSONAL_POD_TYPES.has(String(pod.type))) || pods[0])?._id || '' +); + +export const codeIsLive = (connector: Connector): boolean => Boolean( + connector.config?.connectCode + && connector.config?.connectCodeExpiresAt + && new Date(connector.config.connectCodeExpiresAt).getTime() > Date.now(), +); + +export const codeExpiresInMinutes = (connector: Connector): number => { + const expiry = connector.config?.connectCodeExpiresAt + ? new Date(connector.config.connectCodeExpiresAt).getTime() + : NaN; + if (!Number.isFinite(expiry)) return 0; + return Math.max(1, Math.ceil((expiry - Date.now()) / 60_000)); +}; + +// One connector's age as English prose uses this profile; the gate line below +// needed the same parts (TASK-164). +export const connectorRelativeTimeOptions = { includeFuture: false, missing: 'just now', rounding: 'floor' } as const; + +export const claimIsStale = (installation: CatalogInstallation): boolean => { + const claimedAt = installation.claimedAt ? new Date(installation.claimedAt).getTime() : NaN; + if (!Number.isFinite(claimedAt)) return true; + return Date.now() - claimedAt >= INSTALL_LOCK_TTL_MS; +}; + +export const projectionMissing = (entry: CatalogEntry): boolean => ( + !entry.integration + || entry.integration.isActive === false + || (entry.installation?.components || []).some((component) => component.status === 'stale') +); + +export const botMembers = (pod: V2Pod | undefined): V2PodMember[] => ( + (pod?.members || []).filter((member): member is V2PodMember => ( + typeof member === 'object' && member !== null && Boolean(member.isBot) && Boolean(member.username) + )) +); diff --git a/frontend/src/v2/connectors/connectors.css b/frontend/src/v2/connectors/connectors.css new file mode 100644 index 0000000000..b535f7277e --- /dev/null +++ b/frontend/src/v2/connectors/connectors.css @@ -0,0 +1,114 @@ +/* Connectors, rebuilt from the d215 artboard (TASK-733): one logo grid for + channels and tools, reveal-only descriptions, inactive ones folded. Values are + the frame's own. Colours are v2 tokens (the d215 key gradient and pop shadow + from the Signal tokens), so light, dark and auto switch together. */ +.v2-cx { + --cx-key-a: var(--v2-key-a); + --cx-key-b: var(--v2-key-b); + --cx-key-edge: var(--v2-key-edge); + --cx-pop-bg: var(--v2-key-a); + --cx-pop-shadow: var(--v2-shadow-pop); + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} + +/* the shell's body gives the page its whole pane: the head and the grid pad themselves */ +.v2-feature--connectors > .v2-feature__body { padding: 0; display: flex; flex-direction: column; overflow: hidden; } +.v2-feature--connectors .v2-feature__legacy { flex: 1 1 auto; width: 100%; max-width: none; min-height: 0; margin: 0; display: flex; flex-direction: column; } + +/* head: title, count, search, filter */ +.v2-cx-head { flex: none; display: flex; align-items: center; gap: 12px; min-height: 76px; padding: 0 28px; border-bottom: 1px solid var(--v2-border-soft); } +.v2-cx-head h1 { margin: 0; font: 800 24px/1 var(--v2-font-display, inherit); letter-spacing: -0.03em; white-space: nowrap; } +.v2-cx-head__meta { font: 500 11.5px/1 var(--v2-font-mono, ui-monospace, monospace); color: var(--v2-text-muted); white-space: nowrap; } +.v2-cx-head__sp { flex: 1; } +.v2-cx-search { flex: none; width: 260px; height: 34px; border-radius: 10px; background: var(--v2-bg-subtle); box-shadow: inset 0 0 0 1px var(--v2-border-soft); display: flex; align-items: center; gap: 7px; padding: 0 10px; color: var(--v2-text-placeholder); min-width: 0; } +.v2-cx-search svg { flex: none; width: 15px; height: 15px; } +.v2-cx-search input { all: unset; flex: 1; min-width: 0; font-size: 13px; color: var(--v2-text-primary); } +.v2-cx-search input::placeholder { color: var(--v2-text-placeholder); } +.v2-cx-search:focus-within { box-shadow: 0 0 0 2px var(--v2-accent); } +.v2-cx-seg { display: inline-flex; gap: 2px; padding: 2px; border-radius: 9px; background: var(--v2-surface-tint); box-shadow: inset 0 0 0 1px var(--v2-border-soft); flex: none; } +.v2-cx-seg button { all: unset; box-sizing: border-box; cursor: pointer; height: 28px; padding: 0 10px; border-radius: 7px; display: inline-flex; align-items: center; justify-content: center; font-weight: 500; font-size: 12.5px; line-height: 1; color: var(--v2-text-secondary); white-space: nowrap; } +.v2-cx-seg button[aria-pressed='true'] { background: var(--v2-surface); color: var(--v2-text-primary); font-weight: 600; box-shadow: 0 0 0 1px var(--cx-key-edge), 0 1px 2px rgba(16, 24, 40, 0.08); } +.v2-cx-seg button:focus-visible { box-shadow: 0 0 0 2px var(--v2-accent); } + +/* body */ +.v2-cx-body { position: relative; flex: 1; min-height: 0; overflow-y: auto; padding: 24px 28px; } +.v2-cx-inner { max-width: 1100px; margin: 0 auto; } +.v2-cx-group + .v2-cx-group { margin-top: 28px; } +.v2-cx-group__title { display: flex; align-items: center; gap: 8px; margin: 0 4px 8px; font: 500 11.5px/1 var(--v2-font-mono, ui-monospace, monospace); color: var(--v2-text-muted); } +.v2-cx-off { all: unset; box-sizing: border-box; cursor: pointer; margin-left: auto; padding: 4px; border-radius: 6px; color: var(--v2-text-muted); } +.v2-cx-off:hover { color: var(--v2-text-primary); } +.v2-cx-off:focus-visible { box-shadow: 0 0 0 2px var(--v2-accent); } + +/* grid and tile */ +.v2-cx-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; } +.v2-cx-tile { all: unset; box-sizing: border-box; cursor: pointer; position: relative; display: grid; justify-items: center; align-content: center; gap: 10px; height: 128px; border-radius: 14px; background: linear-gradient(180deg, var(--cx-key-a), var(--cx-key-b)); box-shadow: 0 0 0 1px var(--cx-key-edge), 0 1px 2px rgba(16, 24, 40, 0.06), 0 6px 14px -10px rgba(16, 24, 40, 0.2); transition: box-shadow 140ms, transform 120ms; } +.v2-cx-tile:hover { box-shadow: 0 0 0 1px var(--v2-border), 0 1px 2px rgba(16, 24, 40, 0.06), 0 12px 22px -12px rgba(16, 24, 40, 0.28); } +.v2-cx-tile:focus-visible { box-shadow: 0 0 0 2px var(--v2-accent); } +.v2-cx-tile--selected { box-shadow: 0 0 0 2px var(--v2-accent); } +.v2-cx-tile__logo { width: 40px; height: 40px; } +.v2-cx-logo { display: block; width: 100%; height: 100%; } +.v2-cx-logo--glyph { display: grid; place-items: center; border-radius: 12px; background: var(--v2-surface-tint); color: var(--v2-text-secondary); } +.v2-cx-logo--glyph svg { width: 22px; height: 22px; } +.v2-cx-tile__name { max-width: 100%; padding: 0 8px; box-sizing: border-box; font-size: 13px; font-weight: 600; color: var(--v2-text-secondary); text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-cx-tile__state { position: absolute; top: 10px; right: 10px; display: flex; gap: 4px; align-items: center; color: var(--v2-text-placeholder); } +.v2-cx-tile__beta { font: 500 9.5px/1 var(--v2-font-mono, ui-monospace, monospace); color: var(--v2-text-muted); } +.v2-cx-tile__lock { display: inline-flex; } +.v2-cx-tile__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--v2-status-idle); } +.v2-cx-tile__dot--live { background: var(--v2-success); } +.v2-cx-tile__dot--pending { background: var(--v2-status-pending); } +.v2-cx-tile__dot--attention { background: var(--v2-warning); } +.v2-cx-tile__dot--pulse { animation: v2-cx-pulse 1.6s ease-in-out infinite; } +@keyframes v2-cx-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } +@media (prefers-reduced-motion: reduce) { .v2-cx-tile__dot--pulse { animation: none; } } +.v2-cx-tile--dim .v2-cx-tile__logo { opacity: 0.45; filter: grayscale(1); } +.v2-cx-tile--add { background: transparent; box-shadow: none; border: 1.5px dashed var(--v2-border); color: var(--v2-text-muted); } +.v2-cx-tile--add .v2-cx-tile__name { color: var(--v2-text-muted); font-weight: 500; } +.v2-cx-tile--add .v2-cx-tile__logo { display: grid; place-items: center; } + +/* the reveal */ +.v2-cx-pop { position: absolute; z-index: 10; width: 300px; box-sizing: border-box; padding: 14px; border-radius: 12px; background: var(--cx-pop-bg); box-shadow: var(--cx-pop-shadow); display: grid; gap: 10px; } +.v2-cx-pop__head { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 14px; } +.v2-cx-pop__head .v2-cx-logo { width: 28px; height: 28px; flex: none; } +.v2-cx-pop__beta { font: 500 10.5px/1 var(--v2-font-mono, ui-monospace, monospace); color: var(--v2-text-muted); } +.v2-cx-pop p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--v2-text-secondary); } +.v2-cx-pop__chips { display: flex; flex-wrap: wrap; gap: 4px; } +.v2-cx-chip { display: inline-flex; align-items: center; height: 24px; padding: 0 8px; border-radius: 7px; font: 500 11.5px/1 var(--v2-font-mono, ui-monospace, monospace); color: var(--v2-text-secondary); background: var(--v2-surface); box-shadow: inset 0 0 0 1px var(--v2-border-soft); white-space: nowrap; } +.v2-cx-pop__acts { display: flex; gap: 8px; flex-wrap: wrap; } +.v2-cx-note { font-size: 12.5px; color: var(--v2-text-muted); } +.v2-cx-error { margin: 0 0 12px; font-size: 13px; color: var(--v2-danger-text); } + +/* the grant rows inside a drawer: one stacked card each, not the old tile template */ +.v2-cx-list { display: grid; gap: 10px; } +.v2-cx-list .v2-connector-row { grid-template-columns: minmax(0, 1fr); border: 1px solid var(--v2-border-soft); border-radius: 12px; padding: 12px; } +.v2-root .v2-cx-list button.v2-connector-row__selection { grid-column: 1; grid-template-columns: minmax(0, 1fr); row-gap: 6px; } +.v2-root .v2-cx-list .v2-connector-row__action { grid-column: 1; grid-row: auto; justify-self: start; } +.v2-cx-drawer__offer { display: grid; gap: 10px; } + +/* phone: three to a row, a tab bar below (the shell's), the reveal as a sheet */ +@media (max-width: 640px) { + .v2-cx-head { min-height: 52px; padding: 10px 10px 0 12px; gap: 8px; flex-wrap: wrap; } + .v2-cx-head h1 { font-size: 21px; } + .v2-cx-search { width: auto; flex: 1 1 100%; order: 3; margin: 0 0 10px; } + .v2-cx-body { padding: 12px; } + .v2-cx-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } + .v2-cx-tile { height: 104px; } +} +/* the shell's tab bar is up to 760: the sheet sits above it */ +@media (max-width: 760px) { + .v2-cx-pop { position: fixed; left: 12px !important; right: 12px; bottom: calc(84px + env(safe-area-inset-bottom, 0px)); top: auto !important; width: auto; max-height: calc(100vh - 160px); overflow-y: auto; z-index: 50; } +} + +/* the manage drawer */ +.v2-cx-drawer { position: absolute; z-index: 20; top: 0; right: 0; bottom: 0; width: 400px; max-width: 100%; box-sizing: border-box; display: flex; flex-direction: column; background: var(--v2-surface); box-shadow: -1px 0 0 var(--v2-border-soft), -18px 0 36px -24px rgba(16, 24, 40, 0.28); outline: none; } +.v2-cx-drawer__head { flex: none; display: flex; align-items: center; gap: 10px; height: 64px; padding: 0 12px 0 20px; border-bottom: 1px solid var(--v2-border-soft); } +.v2-cx-drawer__head h2 { flex: 1; min-width: 0; margin: 0; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.v2-cx-drawer__logo { flex: none; display: inline-flex; } +.v2-cx-drawer__close { all: unset; box-sizing: border-box; cursor: pointer; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; color: var(--v2-text-secondary); } +.v2-cx-drawer__close:hover { background: var(--v2-surface-tint); color: var(--v2-text-primary); } +.v2-cx-drawer__close:focus-visible { box-shadow: 0 0 0 2px var(--v2-accent); } +.v2-cx-drawer__body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 20px 24px; display: flex; flex-direction: column; gap: 16px; } +.v2-cx-drawer__body .v2-connectors__aside { gap: 16px; } +@media (max-width: 760px) { .v2-cx-drawer { position: fixed; inset: 0 0 calc(72px + env(safe-area-inset-bottom, 0px)) 0; width: auto; } } diff --git a/frontend/src/v2/connectors/toolModel.ts b/frontend/src/v2/connectors/toolModel.ts new file mode 100644 index 0000000000..cca9ddba0b --- /dev/null +++ b/frontend/src/v2/connectors/toolModel.ts @@ -0,0 +1,159 @@ +// Tool connectors: the shapes the installables and grants routes return, and the +// pure helpers the tool hook and panels share. Moved from the retired +// tools list component (TASK-733); nothing here renders. +import { relativeParts } from '../utils/localizeRelativeTime'; + +export type GrantWriteMode = 'read' | 'write' | 'write-with-confirm'; + +export interface ToolGrant { + grantId: string; + installationId: string; + target: { kind: 'pod' | 'seat'; id: string }; + tools: string[]; + writeMode: GrantWriteMode; + budget: { calls?: number; windowMs?: number } | null; + effectiveAudience: string[]; + expiresAt: string; + revokedAt: string | null; + revokedBy: string | null; + parentGrantId: string | null; + rootGrantId: string | null; + createdAt: string; + grantedBy: string | null; +} + +/** `superseded` is a DERIVED read-only value the server reports for a parked + * request whose approval has since resolved: the call itself is the row beside + * it (ok / refused / failed), and this row is only the ask. It is never stored, + * so it is never what a write sends. */ +export type ToolOutcome = 'ok' | 'refused' | 'pending_approval' | 'failed' | 'superseded'; + +export interface ToolCallLine { + callId: string; + agentUserId: string; + tool: string; + outcome: ToolOutcome; + reason: string | null; + approvalId: string | null; + argsDigest: string; + at: string | null; + durationMs: number | null; +} + +export interface ToolTrail { + grantId: string; + calls: ToolCallLine[]; + counts: { total: number; ok: number; refused: number; pending_approval: number; failed: number }; +} + +/** A tool Installable as GET /api/installables lists it (list: 'tools', #1670). */ +export interface ToolCatalogEntry { + installableId: string; + list?: 'channels' | 'tools'; + label: string; + description: string; + connectNotice?: string; + /** + * Where the person chooses, at the vendor, what the connection can reach + * (GitHub: the App's install page, which picks the repositories). + */ + accessPage?: string; + /** + * The connection type this row's tools run through, and — on a hosted entry — + * the catalogue entry the start route takes. `entryId` is absent on + * `github-app`, whose connections are not per entry. + */ + connectionType?: 'github-app' | 'hosted-mcp'; + entryId?: string; + available: boolean; + unavailableReason?: string; + broker?: { id: string }; + tools: Array<{ name: string; description?: string; requiredWriteMode: GrantWriteMode; irreversible: boolean }>; + connections: Array<{ connectionId: string; owner: string; repo: string }>; +} + +export interface HiddenToolConnection { + connectionId: string; + entryId: string; + label: string; + status: string; + revokePage: string | null; +} + +export interface HostedMcpManualRevokeNotice { + connectionId: string; + entryId: string; + provider: string; + revokePage: string; +} + +export interface PodSeat { userId: string | null; displayName?: string; name: string; internal?: boolean } + + +export interface DraftGrant { + entry: ToolCatalogEntry; + podId: string; + connectionId: string; + writeMode: GrantWriteMode; + audience: string[]; + expiryDays: 7 | 30 | 90; + /** Change access: the grant this one replaces (revoked after the mint). */ + replaces: string | null; +} + +export interface GithubAppSetup { + installationId: string; + owner: string; + repo: string; +} + +export interface GithubAppIntegrationResponse { + integration?: { + createdBy?: string | { _id?: string } | null; + }; +} + +export const USED_RECENTLY_MS = 10 * 60 * 1000; +export const MODE_RANK: Record = { read: 0, 'write-with-confirm': 1, write: 2 }; +// The two option sets the aside renders as buttons. They live here, not inline in the +// JSX, because eslint's i18next/no-literal-string (jsx-only) reads an array literal in +// a JSX expression as copy — and these are ids, not words (TASK-164). +export const WRITE_MODES: GrantWriteMode[] = ['read', 'write-with-confirm', 'write']; + +/** + * The age as a unit-suffixed number ("23d", "5m", "just now"). Rounds through + * relativeParts, the same rule as the "revoked … ago" line, so one timestamp + * pair never reads "1d" in one place and "2d" in the other (TASK-649). + */ +export const shortAge = (date: string | null | undefined, now: number, t: (key: string, options?: Record) => string): string => { + const parts = relativeParts(date, { includeFuture: false, now }); + if (parts.kind !== 'rel') return t('time.age.justNow', { defaultValue: 'just now' }); + if (parts.unit === 'minute') return t('time.age.minutes', { defaultValue: '{{n}}m', n: parts.count }); + if (parts.unit === 'hour') return t('time.age.hours', { defaultValue: '{{n}}h', n: parts.count }); + return t('time.age.days', { defaultValue: '{{n}}d', n: parts.count }); +}; + +// Both surfaces used to carry their own copy of this grammar; the locale family +// lives in one place now (TASK-164). Re-exported so the module's surface is +// unchanged for anything importing it from here. + +export const isExpired = (grant: ToolGrant, now = Date.now()): boolean => new Date(grant.expiresAt).getTime() <= now; +// Callers that decide what a render SHOWS pass that render's `now`; the read +// path (`load`) leaves the default, because there the question is what is +// live at the moment of the read. +export const isDead = (grant: ToolGrant, now = Date.now()): boolean => Boolean(grant.revokedAt) || isExpired(grant, now); + +// A catalogue entry is an Installable; a grant carries tool names and an +// `installationId` that names an install, not a catalogue id. The names are the +// only key both sides share, so an entry is the one whose tools the grant names. +export const entryCovers = (entry: ToolCatalogEntry, grant: ToolGrant): boolean => ( + (grant.tools || []).some((name) => (entry.tools || []).some((tool) => tool.name === name)) +); +// A grant that names no tool of this entry grants nothing on it — that is what +// the enforcement path does (roomGrantService refuses any tool the grant does +// not list), so the page must not read such an entry as granted. +export const entryIsGranted = (entry: ToolCatalogEntry, grants: ToolGrant[], now: number): boolean => ( + grants.some((grant) => !isDead(grant, now) && entryCovers(entry, grant)) +); + +export const PER_ENTRY_REVOKE_COPY = new Set(['supabase', 'linear', 'sentry']); diff --git a/frontend/src/v2/connectors/useChannelConnectors.ts b/frontend/src/v2/connectors/useChannelConnectors.ts new file mode 100644 index 0000000000..4139b93f4e --- /dev/null +++ b/frontend/src/v2/connectors/useChannelConnectors.ts @@ -0,0 +1,1026 @@ +// Channel connectors: state, effects and verbs of the connectors page, moved out +// of the retired channels page so the tile page and its panels share one model +// (TASK-733). The rows' behaviour is unchanged; the page that drew them is gone. +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useV2Api } from '../hooks/useV2Api'; +import { useRelativeNow } from '../hooks/useRelativeNow'; +import { V2Pod } from '../hooks/useV2Pods'; +import type { MarkName } from '../icons/glyphs'; +import { hostedMcpErrorNotice } from '../components/hostedMcpErrorNotice'; +import type { HostedMcpManualRevokeNotice } from './toolModel'; +import { catalogDescription } from '../utils/connectorCatalogDescription'; +import { safeProviderRevokePage } from '../utils/safeProviderRevokePage'; +import { + TYPE_LABELS, + TOOL_ONLY_CONNECTION_TYPES, + mappedTypeLabel, + CONNECT_DETAIL, + RECENT_MS, + TRANSIENT_STATUSES, + installableLifecyclePath, + installErrorResponse, + SLACK_REFUSALS, + connectorPodId, + defaultPodForChannel, + codeIsLive, + codeExpiresInMinutes, + claimIsStale, + projectionMissing, +} from './channelModel'; +import type { + ConnectorGate, + Connector, + CatalogEntry, + CatalogResponse, + InstallResponse, + SlackAuthorizeResponse, + ConnectorAction, + ConnectorRow, + ListItem, +} from './channelModel'; + +export const useChannelConnectors = () => { + const { t, i18n } = useTranslation(); + const api = useV2Api(); + const [connectors, setConnectors] = useState([]); + const [catalog, setCatalog] = useState(null); + const [hostedCatalog, setHostedCatalog] = useState(null); + // `null` means membership is not available yet (or the read failed). An + // empty array is the only confirmed zero-pod state, so it alone may replace + // the existing picker with the create-a-pod path. + const [pods, setPods] = useState(null); + const [newPodId, setNewPodId] = useState(''); + const [addingType, setAddingType] = useState(null); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [creating, setCreating] = useState(false); + const [selectedKey, setSelectedKey] = useState(null); + const [confirmRemove, setConfirmRemove] = useState(null); + const [expandedGate, setExpandedGate] = useState(null); + const [copied, setCopied] = useState(null); + const [error, setError] = useState(null); + const [hostedMcpManualRevoke, setHostedMcpManualRevoke] = useState(null); + // Row C: a refusal raised by a row's own button belongs on that row, not + // only in the page-level slot at the foot of the page. + const [rowRefusal, setRowRefusal] = useState<{ key: string; message: string } | null>(null); + const [slackCallbackError, setSlackCallbackError] = useState(null); + const [slackLeadChoices, setSlackLeadChoices] = useState>({}); + const [hostedMcpCallbackError, setHostedMcpCallbackError] = useState(null); + const pollRef = useRef | null>(null); + const podPickerRef = useRef(null); + // Ages must advance while the page sits open, and the source must be re-read + // when the tab comes back: a row that says `since 5m ago` is wrong twice + // over if it is still saying it an hour later (TASK-131). + const now = useRelativeNow(); + const adding = addingType !== null; + // Memoised: a fresh `[]` each render while `pods` is null would re-fire every effect keyed on it. + const podList = useMemo(() => pods || [], [pods]); + // The gate mode options, declared here rather than inline in the JSX: eslint's + // i18next/no-literal-string reads an array literal in JSX as copy, and only the + // second element of each pair is copy (TASK-164). + const gateModes: [ConnectorGate['mode'], string][] = [ + [undefined, t('connectors.modeDefault', { defaultValue: 'Default' })], + ['attention', t('connectors.modeAttention', { defaultValue: 'Attention' })], + ['mirror', t('connectors.modeMirror', { defaultValue: 'Mirror' })], + ]; + + // TASK-155: at 390 the aside sits below the fold of the page's own scroller + // (`v2-feature__body` is the scroller, not the document), so opening the form + // changed nothing on screen even once it moved into the panel. Focusing the + // picker scrolls it into view, and is where a keyboard user wants the cursor + // anyway. Keyed on the type so switching provider also lands on the picker. + useEffect(() => { + if (addingType) podPickerRef.current?.focus(); + }, [addingType]); + + const load = useCallback(async () => { + try { + const [rows, catalogResponse] = await Promise.all([ + api.get('/api/integrations/user/all'), + // The catalog is the page's authority for provider rows; if it cannot + // be read the legacy list still renders and the picker falls back. + api.get('/api/installables').catch(() => null), + ]); + const connectorRows = Array.isArray(rows) ? rows : []; + const installables = catalogResponse && Array.isArray(catalogResponse.installables) + ? catalogResponse.installables + : null; + setConnectors(connectorRows); + setCatalog(installables ? installables.filter((entry) => entry.list !== 'tools') : null); + setHostedCatalog(installables + ? installables.filter((entry) => entry.list === 'tools' && entry.connectionType === 'hosted-mcp') + : null); + setError(null); + return connectorRows; + } catch { + setError(t('connectors.loadError', { defaultValue: 'Could not load connectors.' })); + return []; + } finally { + setLoading(false); + } + }, [api, t]); + + useEffect(() => { void load(); }, [load]); + + useEffect(() => { + const onVisibility = () => { if (document.visibilityState === 'visible') void load(); }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, [load]); + + // Keep vendor-controlled scope values out of the URL and official UI copy. + // Consume the callback result after displaying a fixed notice. + useEffect(() => { + const query = new URLSearchParams(window.location.search); + if (query.get('hostedMcp') !== 'error') return; + setHostedMcpCallbackError(hostedMcpErrorNotice(t, query.get('code'))); + query.delete('hostedMcp'); + query.delete('code'); + query.delete('extraScope'); + query.delete('error'); + query.delete('error_description'); + const remaining = query.toString(); + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}${remaining ? `?${remaining}` : ''}${window.location.hash}`, + ); + }, [t]); + + // The Slack callback resolves in its own tab. Consume its opaque result and + // leave no state or error code in the browser URL. + 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]); + + // The hosted-MCP callback says when a previous provider authorization may + // need manual attention. Resolve its page from the authenticated connection + // row and match the callback copy to that server-owned value; never turn an + // arbitrary query parameter into a provider link. + useEffect(() => { + const query = new URLSearchParams(window.location.search); + if (query.get('hostedMcp') !== 'connected') return; + // Let the ordinary page load finish first; its authenticated row is the + // authority for the provider link, so there is no second request here. + if (loading || hostedCatalog === null) return; + const entryId = query.get('entryId'); + const revokeAt = query.get('revokeAt'); + if (entryId && revokeAt) { + const row = connectors.find((candidate) => ( + candidate.type === 'hosted-mcp' && candidate.config?.entryId === entryId + )); + const revokePage = row?.config?.revokePage; + const provider = hostedCatalog?.find((entry) => entry.entryId === entryId)?.label; + if (row && provider && revokePage === revokeAt && safeProviderRevokePage(revokePage)) { + setHostedMcpManualRevoke({ + connectionId: row._id, + entryId, + provider, + revokePage, + }); + } + } + query.delete('hostedMcp'); + query.delete('entryId'); + query.delete('revokeAt'); + query.delete('code'); + const remaining = query.toString(); + window.history.replaceState( + window.history.state, + '', + `${window.location.pathname}${remaining ? `?${remaining}` : ''}${window.location.hash}`, + ); + }, [connectors, hostedCatalog, loading]); + + const hasPending = connectors.some((connector) => connector.status !== 'connected' && ( + (connector.type === 'telegram' && codeIsLive(connector)) + || (connector.type === 'slack' && (codeIsLive(connector) || Boolean(connector.config?.pendingBind))) + )) || (catalog || []).some((entry) => ( + entry.installation ? TRANSIENT_STATUSES.includes(entry.installation.status) : false + )); + useEffect(() => { + if (hasPending && !pollRef.current) pollRef.current = setInterval(load, 3000); + if (!hasPending && pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + return () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + }, [hasPending, load]); + + useEffect(() => { + let cancelled = false; + void (async () => { + try { + // The server decides this. A pod the user is listed in is a connector + // target UNLESS its type is one the default listing hides (`agent-admin`): + // installing into one, gating one, or making one active all 403, so this + // list — built from that same endpoint — offers exactly what those verbs + // accept (TASK-171). + const data = await api.get('/api/pods'); + const eligible = Array.isArray(data) ? data : []; + if (!cancelled) { + setPods(eligible); + setNewPodId((current) => current || defaultPodForChannel(eligible)); + } + } catch { + // Keep the normal picker visible when membership cannot be read. A + // zero-pod CTA requires a confirmed `[]`, never an unavailable read. + } + })(); + return () => { cancelled = true; }; + }, [api]); + + const untitledPodName = (): string => t('connectors.untitledPod', { defaultValue: 'Untitled pod' }); + + const podNameById = (podId: string | null | undefined, fallback?: Connector | null): string => { + const pod = podList.find((candidate) => String(candidate._id) === String(podId || '')); + if (pod) return pod.name || untitledPodName(); + if (fallback && typeof fallback.podId === 'object' && fallback.podId?.name) return fallback.podId.name; + return untitledPodName(); + }; + + const boundPodName = (boundPodId: string | undefined): string => { + const existing = connectors.find((connector) => connectorPodId(connector) === String(boundPodId)); + const boundPod = podList.find((pod) => String(pod._id) === String(boundPodId)); + return existing + ? podNameById(connectorPodId(existing), existing) + : (boundPod?.name || t('connectors.anotherPod', { defaultValue: 'another pod' })); + }; + + const installInProgressMessage = (boundPodId?: string): string => ( + boundPodId + ? t('connectors.installInProgressForPod', { + defaultValue: 'Still setting up for {{pod}} — try again in a moment.', + pod: boundPodName(boundPodId), + }) + : t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' }) + ); + + const install = async (type: string, podId: string): Promise => { + const typeLabel = TYPE_LABELS[type] || type; + try { + const result = await api.post(installableLifecyclePath(type), { podId }); + // Reload before reporting: `load` clears the error slot on success, and + // the row's new state is what the message refers to. + await load(); + if (result.status && result.status !== 'active') { + setError(installInProgressMessage(result.boundPodId)); + return false; + } + return true; + } catch (requestError) { + const response = installErrorResponse(requestError); + let message: string; + if (response.status === 409 && response.data?.code === 'install_in_progress') { + message = installInProgressMessage(response.data.boundPodId); + } else if (response.status === 409 && response.data?.code === 'already_installed') { + message = t('connectors.alreadyBound', { + // TASK-173: name the controls the page actually shows. "gate" is + // this file's internal name for the switches (ConnectorGate, + // expandedGate, writeGate) and it had leaked into the only + // user-visible string on the page that used it. The section is + // `connectors.gatesTitle` ("Pods that reach this channel", :1106). + // + // The aside that HOLDS that section is labelled "Channel details" + // (:1220, :1286) — an `aria-label`, not visible text, so naming it in + // the copy sent a sighted user hunting for a string the page never + // renders. That is the same defect as "gate switches" sourced from an + // attribute instead of an identifier (TASK-173 gate), so the + // copy names the section title alone. + defaultValue: 'Your {{connector}} channel is bound to {{pod}}. Other pods reach it through "Pods that reach this channel" — switch a pod on there.', + connector: typeLabel, + pod: boundPodName(response.data.boundPodId), + }); + } else if (response.status === 409 && response.data?.code === 'installation_paused') { + message = t('connectors.pausedRefusal', { + defaultValue: 'An administrator paused this connector. Ask your operator.', + }); + } else if (response.status === 422 && response.data?.code === 'provider_not_configured') { + message = t('connectors.providerNotConfigured', { + defaultValue: '{{connector}} is not enabled on this instance. Ask your operator.', + connector: typeLabel, + }); + } else { + message = t('connectors.createError', { defaultValue: 'Could not create the connector.' }); + } + await load(); + setError(message); + return false; + } + }; + + const createConnector = async () => { + if (!newPodId || !addingType || creating) return; + setCreating(true); + setError(null); + try { + const installed = await install(addingType, newPodId); + if (installed) setAddingType(null); + } finally { + setCreating(false); + } + }; + + const patchIntegration = async (connector: Connector, body: Record, errorKey: string, errorDefault: string) => { + if (busyId) return; + setBusyId(connector._id); + setError(null); + try { + await api.patch(`/api/integrations/${connector._id}`, body); + await load(); + } catch { + setError(t(errorKey, { defaultValue: errorDefault })); + } finally { + setBusyId(null); + } + }; + + const patchConfig = (connector: Connector, config: Record, errorKey: string, errorDefault: string) => ( + patchIntegration(connector, { config }, errorKey, errorDefault) + ); + + // The PATCH merges `config` one level deep, so `gates` is written whole: + // every pod's gate travels with the one that changed, `since` included. + // The server validates every key against membership and refuses the whole + // body on the first miss, so a gate for a pod the owner has since left + // (not yet pruned by the reconciler) is dropped here rather than sent — + // otherwise every toggle would 403 for up to one sweep. + const writeGate = (connector: Connector, podId: string, next: ConnectorGate) => { + const current = connector.config?.gates || {}; + const memberOf = new Set(podList.map((pod) => String(pod._id))); + const gates: Record = {}; + Object.entries(current).forEach(([key, gate]) => { + if (!memberOf.has(String(key))) return; + gates[key] = { + enabled: gate.enabled === true, + ...(gate.mode ? { mode: gate.mode } : {}), + ...(gate.lead ? { lead: gate.lead } : {}), + since: gate.since || new Date().toISOString(), + }; + }); + gates[podId] = { + enabled: next.enabled === true, + ...(next.mode ? { mode: next.mode } : {}), + ...(next.lead ? { lead: next.lead } : {}), + since: current[podId]?.since || new Date().toISOString(), + }; + return patchConfig(connector, { gates }, 'connectors.gateError', 'Could not update that pod.'); + }; + + const makeActive = (connector: Connector, podId: string) => ( + patchIntegration(connector, { podId }, 'connectors.activePodError', 'Could not change the active pod.') + ); + + const regenerateCode = async (connector: Connector) => { + if (busyId) return; + setBusyId(connector._id); + setError(null); + try { + await api.post(`/api/integrations/${connector._id}/connect-code`, {}); + await load(); + } catch { + setError(t('connectors.codeError', { defaultValue: 'Could not create a new code.' })); + } finally { + setBusyId(null); + } + }; + + // The refusal is named from the response body, not swallowed: a bare catch + // here is what hid `slack_already_authorized` from the row (Row C). + const refusalCopy = (requestError: unknown, fallbackKey: string, fallbackDefault: string): string => { + const { data } = installErrorResponse(requestError); + const known = data?.code ? SLACK_REFUSALS[data.code] : undefined; + if (known) return t(known.key, { defaultValue: known.defaultValue }); + if (data?.error) return data.error; + return t(fallbackKey, { defaultValue: fallbackDefault }); + }; + + const authorizeSlack = async (connector: Connector, rowKey: string) => { + if (busyId) return; + const authorizationWindow = window.open('', '_blank'); + setBusyId(connector._id); + setError(null); + setRowRefusal(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'); + if (authorizationWindow) { + authorizationWindow.opener = null; + authorizationWindow.location.assign(result.authorizeUrl); + } else { + window.location.assign(result.authorizeUrl); + } + } catch (requestError) { + authorizationWindow?.close(); + setRowRefusal({ + key: rowKey, + message: refusalCopy(requestError, 'connectors.slackAuthorizeError', 'Could not begin Slack authorization. Try again in a moment.'), + }); + } finally { + setBusyId(null); + } + }; + + const resolveSlackBind = async (connector: Connector, action: 'confirm' | 'reject', rowKey: string, lead?: string) => { + if (busyId) return; + setBusyId(connector._id); + setError(null); + setRowRefusal(null); + try { + await api.post(`/api/installables/slack/${action}`, action === 'confirm' && lead + ? { leadAgentUsername: lead } : {}); + await load(); + } catch (requestError) { + setRowRefusal({ + key: rowKey, + message: refusalCopy(requestError, 'connectors.slackBindError', 'Could not update the Slack connection. Try again in a moment.'), + }); + } finally { + setBusyId(null); + } + }; + + // DELETE on the lifecycle verb serves Remove, Cancel and Retry remove alike: + // the service decides what a DELETE means from the parent's state. + const uninstall = async (type: string, busyKey: string, errorKey: string, errorDefault: string) => { + if (busyId) return; + setBusyId(busyKey); + setError(null); + try { + await api.del(installableLifecyclePath(type)); + setConfirmRemove(null); + } catch (requestError) { + const response = installErrorResponse(requestError); + // A live claim refuses Cancel; the row simply reads "Setting up…" again. + if (!(response.status === 409 && response.data?.code === 'install_in_progress')) { + setError(t(errorKey, { defaultValue: errorDefault })); + } + } finally { + setBusyId(null); + await load(); + } + }; + + const remove = async (item: ListItem) => { + const connector = item.connector; + if (item.kind === 'catalog') { + await uninstall(item.entry.installableId, item.key, 'connectors.removeError', 'Could not remove the channel.'); + setSelectedKey(null); + return; + } + if (!connector) return; + setBusyId(connector._id); + try { + if (connector.installationId) { + await api.del(installableLifecyclePath(connector.type)); + } else { + await api.patch(`/api/integrations/${connector._id}`, { isActive: false }); + } + setConfirmRemove(null); + setSelectedKey(null); + await load(); + } catch { + setError(t('connectors.removeError', { defaultValue: 'Could not remove the channel.' })); + } finally { + setBusyId(null); + } + }; + + const retry = async (entry: CatalogEntry) => { + if (busyId) return; + const podId = entry.installation?.boundPodId || connectorPodId(entry.integration) || newPodId; + if (!podId) { + setAddingType(entry.installableId); + return; + } + setBusyId(entry.installableId); + setError(null); + try { + await install(entry.installableId, podId); + } finally { + setBusyId(null); + } + }; + + const copyCommand = async (code: string) => { + const command = `/commonly-enable ${code}`; + try { + await navigator.clipboard.writeText(command); + setCopied(code); + setTimeout(() => setCopied(null), 2000); + } catch { + // Clipboard access is optional; the command remains selectable. + } + }; + + // The age as a unit-suffixed number from the timestamp ("23d", "5m", "just now"), + // and a verb line built from a key — the kicker's source of truth. + const shortAge = (date?: string | null): string => { + const timestamp = date ? new Date(date).getTime() : NaN; + if (!Number.isFinite(timestamp)) return t('time.age.justNow', { defaultValue: 'just now' }); + const minutes = Math.max(0, Math.floor((now - timestamp) / 60_000)); + if (minutes < 1) return t('time.age.justNow', { defaultValue: 'just now' }); + if (minutes < 60) return t('time.age.minutes', { defaultValue: '{{n}}m', n: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t('time.age.hours', { defaultValue: '{{n}}h', n: hours }); + return t('time.age.days', { defaultValue: '{{n}}d', n: Math.floor(hours / 24) }); + }; + const ageLine = (verb: 'added' | 'started' | 'since' | 'paused' | 'slackAnswered', date?: string | null): string => { + const age = shortAge(date); + switch (verb) { + case 'added': return t('connectors.age.added', { defaultValue: 'added {{age}}', age }); + case 'started': return t('connectors.age.started', { defaultValue: 'started {{age}}', age }); + case 'since': return t('connectors.age.since', { defaultValue: 'since {{age}}', age }); + case 'paused': return t('connectors.age.paused', { defaultValue: 'paused {{age}}', age }); + default: return t('connectors.age.slackAnswered', { defaultValue: 'Slack answered {{age}}', age }); + } + }; + + const rowFor = (connector: Connector): ConnectorRow => { + const started = ageLine('started', connector.createdAt); + const isTelegram = connector.type === 'telegram'; + const isSlack = connector.type === 'slack'; + const title = connector.config?.chatTitle || connector.config?.teamName || TYPE_LABELS[connector.type] || connector.type; + + if (connector.status === 'error') { + return { + action: isTelegram ? 'new-code' : isSlack ? 'authorize' : null, + actionLabel: isTelegram ? t('connectors.newCode', { defaultValue: 'New code' }) : t('connectors.slackAuthorize', { defaultValue: 'Authorize in Slack' }), + detail: t('connectors.errorReconnect', { defaultValue: 'reconnect to resume' }), + dot: 'empty', + line: connector.errorMessageUserFacing && connector.errorMessage + ? connector.errorMessage + : t('connectors.errorLine', { defaultValue: 'The connection dropped.' }), + pulse: false, + when: ageLine('since', connector.updatedAt || connector.createdAt), + }; + } + + if (connector.status === 'connected') { + const activePodId = connectorPodId(connector); + if (!activePodId) { + // The owner left the active pod and the prune cleared it (D3). Their + // typed messages have nowhere to go until they pick a pod in the aside. + return { + action: 'pick-pod', + actionLabel: t('connectors.pickPod', { defaultValue: 'Pick a pod' }), + detail: t('connectors.notLinkedDetail', { defaultValue: 'your messages have nowhere to go' }), + dot: 'idle', + line: t('connectors.notLinkedLine', { defaultValue: '{{title}} · not linked to a pod', title }), + pulse: false, + when: ageLine('added', connector.createdAt), + }; + } + const relay = Boolean(connector.config?.liveRelay); + const mirror = connector.config?.relayAllAgentMessages === true; + const recent = connector.updatedAt && (Date.now() - new Date(connector.updatedAt).getTime()) < RECENT_MS; + const markName: MarkName = relay ? (mirror ? 'mirror' : 'attention') : 'off'; + const markLabel = relay + ? (mirror + ? t('connectors.rowMirror', { defaultValue: 'every agent line reaches the channel' }) + : t('connectors.rowAttention', { defaultValue: 'escalations reach the channel' })) + : t('connectors.rowRelayOff', { defaultValue: 'messages stay in the pod' }); + return { + action: (isTelegram || isSlack) ? 'manage' : null, + actionLabel: t('connectors.manage', { defaultValue: 'Manage' }), + detail: '', + mark: { + name: markName, + label: markLabel, + word: relay + ? (mirror ? t('connectors.kickerMirror', { defaultValue: 'mirror' }) : t('connectors.kickerAttention', { defaultValue: 'attention' })) + : t('connectors.kickerOff', { defaultValue: 'relay off' }), + }, + dot: relay ? 'live' : 'idle', + line: t('connectors.linkedLine', { defaultValue: '{{title}} · linked to {{pod}}', title, pod: podNameById(activePodId, connector) }), + pulse: relay && Boolean(recent), + secondary: true, + when: ageLine('added', connector.createdAt), + }; + } + + if (isTelegram && codeIsLive(connector)) { + return { + action: 'show-code', + actionLabel: t('connectors.showCode', { defaultValue: 'Show code' }), + detail: t('connectors.codeExpires', { + defaultValue: 'Code expires in {{minutes}} min', + minutes: codeExpiresInMinutes(connector), + }), + dot: 'pending', + line: t('connectors.waitingTelegram', { + defaultValue: 'Send /commonly-enable in your Telegram chat.', + }), + pulse: true, + when: started, + }; + } + + if (isTelegram) { + return { + action: 'new-code', + actionLabel: t('connectors.newCode', { defaultValue: 'New code' }), + detail: t('connectors.nothingSent', { defaultValue: 'nothing was sent' }), + dot: 'empty', + line: t('connectors.codeExpired', { defaultValue: 'The enable code expired.' }), + pulse: false, + when: started, + }; + } + + if (isSlack && connector.config?.pendingBind) { + const workspace = connector.config.pendingBind.teamName || t('connectors.thisSlackWorkspace', { defaultValue: 'This Slack workspace' }); + const user = connector.config.pendingBind.slackUserName + ? `@${connector.config.pendingBind.slackUserName}` + : t('connectors.yourSlackUser', { defaultValue: 'your Slack user' }); + return { + action: 'confirm', + actionLabel: t('connectors.slackConfirm', { defaultValue: 'Confirm' }), + detail: t('connectors.slackConfirmDetail', { defaultValue: 'waiting for you to confirm' }), + dot: 'pending', + line: t('connectors.slackConfirmRow', { defaultValue: '{{workspace}} says {{user}} connected — is that you?', workspace, user }), + pulse: true, + when: ageLine('slackAnswered', connector.updatedAt || connector.createdAt), + }; + } + + if (isSlack) { + return { + action: 'authorize', + actionLabel: t('connectors.slackAuthorize', { defaultValue: 'Authorize in Slack' }), + detail: t('connectors.slackAuthorizeDetail', { defaultValue: 'one click in Slack' }), + dot: 'empty', + line: t('connectors.slackAuthorizeHint', { defaultValue: 'Authorize Commonly in your Slack workspace to connect your DM.' }), + pulse: false, + when: started, + }; + } + + return { + action: null, + detail: t('connectors.pending', { defaultValue: 'Waiting for the channel' }), + dot: 'empty', + line: t('connectors.waitingToConnect', { defaultValue: '{{label}} is waiting to connect.', label: TYPE_LABELS[connector.type] || connector.type }), + pulse: false, + when: started, + }; + }; + + // D2: the catalog entry and its parent decide the row; the Integration row + // only takes over once the parent is `active` and its projection is whole. + const rowForEntry = (entry: CatalogEntry, connector: Connector | null): ConnectorRow => { + const label = entry.label || TYPE_LABELS[entry.installableId] || entry.installableId; + const installation = entry.installation; + if (!entry.available) { + return { + action: null, + // The copy decision here is main's, not this branch's: this commit set + // `detail: ''` citing the 09-04 one-state-line rule, but #1557 landed + // the detail AFTER that ruling and its tests assert the text is present. + // A rebase is not the place to re-open it, so the line is restored and + // raised on the row; everything else in the commit is unchanged. + detail: t('connectors.askOperator', { defaultValue: 'ask your operator' }), + dot: 'not-yet', + line: t('connectors.notEnabled', { defaultValue: 'Not offered on this instance.' }), + muted: true, + notEnabled: true, + pulse: false, + when: '—', + }; + } + // Usable here, but with nothing to install: declaring readiness is a claim + // about this instance's keys, not about whether a row exists to install. + // Showing the Add anyway is what produced a lowercase `discord` row whose + // click ended in 404 installable_not_found. + if (entry.offered === false) { + return { + action: null, + detail: '', + dot: 'not-yet', + line: t('connectors.notConnectable', { defaultValue: 'Unavailable here.' }), + muted: true, + // The class and the Ask link are right for this row; the kicker was the + // part that lied. Measured in the keys-set render at #1826: the eyebrow + // read "not enabled" directly above "Not connectable yet.", i.e. asking + // an operator for credentials the operator had already installed. + notConnectable: true, + notEnabled: true, + pulse: false, + when: '—', + }; + } + if (!installation) { + const connectDetail = CONNECT_DETAIL[entry.installableId]; + return { + action: 'connect', + // The row action opens the pod picker; the form's Connect button is + // the actual install action. Distinct labels keep the two-step flow + // legible to a stranger. + actionLabel: t('connectors.add', { defaultValue: 'Add' }), + detail: connectDetail + ? t(connectDetail.key, { defaultValue: connectDetail.defaultValue }) + : '', + dot: 'empty', + line: catalogDescription(i18n, entry.installableId, entry.description) || t('connectors.availableLine', { defaultValue: 'Connect {{label}} to a pod.', label }), + pulse: false, + when: t('connectors.notConnected', { defaultValue: 'not connected' }), + }; + } + const since = ageLine('since', installation.updatedAt); + const started = ageLine('started', installation.claimedAt || installation.updatedAt); + const stale = claimIsStale(installation); + if (installation.status === 'installing' || installation.status === 'activating') { + return stale + ? { + action: 'cancel', + actionLabel: t('connectors.cancel', { defaultValue: 'Cancel' }), + detail: t('connectors.serverLetGo', { defaultValue: 'the server let go of it' }), + dot: 'pending', + line: t('connectors.setupSlow', { defaultValue: 'Setup is taking longer than it should.' }), + pulse: true, + secondary: true, + when: started, + } + : { + action: null, + detail: t('connectors.waitingServer', { defaultValue: 'waiting for the server' }), + dot: 'pending', + line: t('connectors.settingUp', { defaultValue: 'Setting up…' }), + pulse: true, + when: started, + }; + } + if (installation.status === 'uninstalling') { + return stale + ? { + action: 'retry-remove', + actionLabel: t('connectors.retryRemove', { defaultValue: 'Retry remove' }), + detail: t('connectors.serverLetGo', { defaultValue: 'the server let go of it' }), + dot: 'pending', + line: t('connectors.removeSlow', { defaultValue: 'Removal is taking longer than it should.' }), + pulse: true, + secondary: true, + when: started, + } + : { + action: null, + detail: t('connectors.waitingServer', { defaultValue: 'waiting for the server' }), + dot: 'pending', + line: t('connectors.removing', { defaultValue: 'Removing…' }), + pulse: true, + when: started, + }; + } + if (installation.status === 'error') { + return { + action: 'retry', + actionLabel: t('connectors.retry', { defaultValue: 'Retry' }), + detail: t('connectors.retryOrRemove', { defaultValue: 'retry, or remove it' }), + dot: 'empty', + line: installation.errorMessageUserFacing && installation.errorMessage + ? installation.errorMessage + : t('connectors.setupFailed', { defaultValue: 'Setup didn’t finish.' }), + pulse: false, + when: since, + }; + } + if (installation.status === 'paused') { + const pause = connector?.config?.adminPause; + const reason = pause?.reason ? ` ${pause.reason}` : ''; + return { + action: null, + detail: t('connectors.askOperator', { defaultValue: 'ask your operator' }), + dot: 'empty', + line: `${t('connectors.pausedLine', { defaultValue: 'Paused by an administrator.' })}${reason}`, + pulse: false, + when: ageLine('paused', pause?.at || installation.updatedAt), + }; + } + if (installation.status === 'stale') { + return { + action: null, + detail: t('connectors.notRelaying', { defaultValue: 'not relaying' }), + dot: 'empty', + line: t('connectors.pausedShort', { defaultValue: 'Paused.' }), + pulse: false, + when: since, + }; + } + if (projectionMissing(entry) || !connector) { + return { + action: 'retry', + actionLabel: t('connectors.retry', { defaultValue: 'Retry' }), + detail: t('connectors.retryRebuilds', { defaultValue: 'retry rebuilds it' }), + dot: 'empty', + line: t('connectors.recordGone', { defaultValue: 'The channel record is gone.' }), + pulse: false, + when: since, + }; + } + return rowFor(connector); + }; + + const connectorForEntry = (entry: CatalogEntry): Connector | null => { + if (!entry.integration) return null; + const podId = connectorPodId(entry.integration); + return { + ...entry.integration, + type: entry.integration.type || entry.installableId, + installationId: entry.integration.installationId || 'catalog', + podId: podId ? { _id: podId, name: podNameById(podId, entry.integration) } : null, + }; + }; + + const catalogTypes = new Set((catalog || []).map((entry) => entry.installableId)); + // D215: a provider this instance does not offer is not listed, so a connector + // nobody here has walked is never shown. The API still reports it (the + // offerability contract), and someone who already installed it keeps their + // row, which renders in the not-connectable state below. + const listedCatalog = (catalog || []).filter((entry) => ( + entry.offered !== false || Boolean(entry.installation) || Boolean(entry.integration) + )); + const allItems: ListItem[] = [ + ...listedCatalog.map((entry): ListItem => ({ + kind: 'catalog', + key: `catalog:${entry.installableId}`, + entry, + connector: connectorForEntry(entry), + })), + // Rows the catalog does not describe: legacy pod-scoped connectors, and + // installable rows only while the catalog itself cannot be read. + ...connectors + .filter((connector) => ( + !TOOL_ONLY_CONNECTION_TYPES.has(connector.type) + && !(connector.installationId && catalogTypes.has(connector.type)) + )) + .map((connector): ListItem => ({ kind: 'legacy', key: `legacy:${connector._id}`, connector })), + ]; + + const rowForItem = (item: ListItem): ConnectorRow => ( + item.kind === 'catalog' ? rowForEntry(item.entry, item.connector) : rowFor(item.connector) + ); + + const itemLabel = (item: ListItem): string | null => ( + item.kind === 'catalog' + ? (item.entry.label?.trim() || mappedTypeLabel(item.entry.installableId)) + : mappedTypeLabel(item.connector.type) + ); + + // A backend type is an identifier, not a customer-facing name. Unknown types + // have no Channels row until the catalog provides a label or this map does. + const items = allItems.filter((item) => Boolean(itemLabel(item))); + const connectedCount = items.filter((item) => item.connector?.status === 'connected').length; + const itemType = (item: ListItem): string => (item.kind === 'catalog' ? item.entry.installableId : item.connector.type); + + const needsUser = (row: ConnectorRow): boolean => row.action !== null && !row.secondary && row.action !== 'connect'; + const selectItem = (item: ListItem) => { + setSelectedKey(item.key); + setConfirmRemove(null); + setExpandedGate(null); + // TASK-155: while the form is open it owns the aside, so a row click or a + // row's gear has to close it — otherwise the click looks like it did + // nothing, which is the symptom this page was reported for pointed the + // other way. `runAction` calls this first and re-opens the form for + // `connect`, so Add is unaffected. + setAddingType(null); + }; + + const runAction = (item: ListItem, action: ConnectorAction) => { + selectItem(item); + const connector = item.connector; + if (action === 'connect') { + setAddingType(itemType(item)); + return undefined; + } + // Pick a pod: the aside's gate list is already expanded for an unlinked + // row (D4), so selecting the row is the whole action. + if (action === 'pick-pod') return undefined; + if (item.kind === 'catalog') { + if (action === 'cancel') return uninstall(item.entry.installableId, item.key, 'connectors.cancelError', 'Could not cancel setup.'); + if (action === 'retry-remove') return uninstall(item.entry.installableId, item.key, 'connectors.removeError', 'Could not remove the channel.'); + if (action === 'retry') return retry(item.entry); + } + if (!connector) return undefined; + if (action === 'new-code') return regenerateCode(connector); + if (action === 'authorize') return authorizeSlack(connector, item.key); + // The row opens the aside where the active pod's lead can be chosen. + // Sending a confirm here would silently omit that choice. + if (action === 'confirm') return undefined; + return undefined; + }; + + const busyFor = (item: ListItem): boolean => busyId === item.key || (item.connector !== null && busyId === item.connector._id); + + return { + connectors, + setConnectors, + catalog, + setCatalog, + hostedCatalog, + setHostedCatalog, + pods, + setPods, + newPodId, + setNewPodId, + addingType, + setAddingType, + loading, + setLoading, + busyId, + setBusyId, + creating, + setCreating, + selectedKey, + setSelectedKey, + confirmRemove, + setConfirmRemove, + expandedGate, + setExpandedGate, + copied, + setCopied, + error, + setError, + hostedMcpManualRevoke, + setHostedMcpManualRevoke, + rowRefusal, + setRowRefusal, + slackCallbackError, + setSlackCallbackError, + slackLeadChoices, + setSlackLeadChoices, + hostedMcpCallbackError, + setHostedMcpCallbackError, + pollRef, + podPickerRef, + now, + adding, + podList, + gateModes, + load, + hasPending, + untitledPodName, + podNameById, + boundPodName, + installInProgressMessage, + install, + createConnector, + patchIntegration, + patchConfig, + writeGate, + makeActive, + regenerateCode, + refusalCopy, + authorizeSlack, + resolveSlackBind, + uninstall, + remove, + retry, + copyCommand, + shortAge, + ageLine, + rowFor, + rowForEntry, + connectorForEntry, + catalogTypes, + listedCatalog, + allItems, + rowForItem, + itemLabel, + items, + connectedCount, + itemType, + needsUser, + selectItem, + runAction, + busyFor, + }; +}; + +export type ChannelConnectorsModel = ReturnType; diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts new file mode 100644 index 0000000000..e292018a77 --- /dev/null +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -0,0 +1,592 @@ +// Tool connectors: state, effects and verbs of the Tools list, moved out of the +// retired tools list so the tile page and its panels share one model (TASK-733). +// The page draws it as tiles and a drawer. +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useV2Api } from '../hooks/useV2Api'; +import { useRelativeNow } from '../hooks/useRelativeNow'; +import { useAuth } from '../../context/AuthContext'; +import { V2Pod } from '../hooks/useV2Pods'; +import { hostedMcpErrorNotice } from '../components/hostedMcpErrorNotice'; +import { safeProviderRevokePage } from '../utils/safeProviderRevokePage'; +import { + USED_RECENTLY_MS, + MODE_RANK, + WRITE_MODES, + isDead, + entryCovers, +} from './toolModel'; +import type { + GrantWriteMode, + ToolGrant, + ToolOutcome, + ToolTrail, + ToolCatalogEntry, + HiddenToolConnection, + PodSeat, + DraftGrant, + GithubAppSetup, + GithubAppIntegrationResponse, + HostedMcpManualRevokeNotice, +} from './toolModel'; + +export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpManualRevokeNotice | null = null) => { + const { t } = useTranslation(); + const api = useV2Api(); + // Revoke and Change access are the granter's: the route 403s anyone else. + const { currentUser } = useAuth(); + const viewerId = currentUser?._id ? String(currentUser._id) : ''; + const isGranter = (grant: ToolGrant): boolean => Boolean(viewerId) && grant.grantedBy === viewerId; + const [rawGrants, setGrants] = useState(null); + const [rawCatalog, setCatalog] = useState([]); + // A connection the server confirmed removed stays removed on this page, as a stale catalogue read could bring it back. + const [disconnectedHere, setDisconnectedHere] = useState([]); + const [hiddenConnections, setHiddenConnections] = useState([]); + const [seats, setSeats] = useState>({}); + const [lastUse, setLastUse] = useState>({}); + const [trail, setTrail] = useState(null); + const [trailError, setTrailError] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [githubAppSetup, setGithubAppSetup] = useState(null); + // Change access minted the new grant but the revoke of the old one failed: only the revoke is retried. + const [staleAfterChange, setStaleAfterChange] = useState<{ oldGrantId: string; newGrantId: string } | null>(null); + const [confirmRevoke, setConfirmRevoke] = useState(null); + const [confirmDisconnect, setConfirmDisconnect] = useState(null); + // A revoke is irreversible, so once the server has said yes the grant stays + // revoked on this page whatever a later read returns. Without this, the card + // kept saying "granted" when the reload came back stale or failed. + const [revokedHere, setRevokedHere] = useState>({}); + const [confirmHiddenRemoval, setConfirmHiddenRemoval] = useState(null); + const [removedHiddenNotice, setRemovedHiddenNotice] = useState<{ label: string; revokePage: string | null } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [joinPolicyFixPodId, setJoinPolicyFixPodId] = useState(null); + const [policyFixedPodId, setPolicyFixedPodId] = useState(null); + const [query, setQuery] = useState(''); + const catalog = useMemo(() => (disconnectedHere.length === 0 ? rawCatalog : rawCatalog.map((entry) => ({ + ...entry, + connections: entry.connections.filter((connection) => !disconnectedHere.includes(connection.connectionId)), + }))), [rawCatalog, disconnectedHere]); + const grants = useMemo(() => (rawGrants === null ? null : rawGrants.map((grant) => ( + revokedHere[grant.grantId] && !grant.revokedAt + ? { ...grant, revokedAt: revokedHere[grant.grantId], revokedBy: grant.revokedBy ?? (viewerId || null) } + : grant + ))), [rawGrants, revokedHere, viewerId]); + // Grants and trail rows carry relative ages; they must advance while the + // page sits open and be re-read when the tab comes back (TASK-131). + const now = useRelativeNow(); + + const isAdmin = currentUser?.role === 'admin'; + + const podIds = useMemo(() => pods.map((pod) => String(pod._id)), [pods]); + + const load = useCallback(async () => { + const catalogRes = await api.get<{ installables?: ToolCatalogEntry[]; hiddenToolConnections?: HiddenToolConnection[] }>('/api/installables').catch(() => null); + setCatalog((catalogRes?.installables || []).filter((entry) => entry.list === 'tools')); + setHiddenConnections(catalogRes?.hiddenToolConnections || []); + const [grantRes, seatResults] = await Promise.all([ + api.get<{ grants: ToolGrant[] }>('/api/grants', { params: { connectionOwner: 'me' } }).catch(() => null), + Promise.all(podIds.map(async (podId) => { + const res = await api.get<{ agents?: PodSeat[] }>(`/api/registry/pods/${podId}/agents`).catch(() => null); + return [podId, (res?.agents ?? []).filter((seat) => !seat.internal)] as const; + })), + ]); + const ownedGrants = grantRes?.grants || []; + // A failed read must not blank a list the person is looking at. + if (grantRes) setGrants(ownedGrants); else setGrants((current) => current ?? []); + setSeats(Object.fromEntries(seatResults)); + // "used in the last 10 min" reads the newest trail row's `at` (§6). + const uses = await Promise.all(ownedGrants.filter((grant) => !isDead(grant)).map(async (grant) => { + const res = await api.get(`/api/grants/${grant.grantId}/calls`, { params: { limit: 1 } }).catch(() => null); + return [grant.grantId, res?.calls?.[0]?.at ?? null] as const; + })); + setLastUse(Object.fromEntries(uses)); + }, [api, podIds]); + + useEffect(() => { void load(); }, [load]); + + useEffect(() => { + const onVisibility = () => { if (document.visibilityState === 'visible') void load(); }; + document.addEventListener('visibilitychange', onVisibility); + return () => document.removeEventListener('visibilitychange', onVisibility); + }, [load]); + + useEffect(() => { + if (!selectedId) { setTrail(null); return undefined; } + let cancelled = false; + setTrail(null); + setTrailError(false); + api.get(`/api/grants/${selectedId}/calls`) + .then((res) => { if (!cancelled) setTrail(res); }) + .catch(() => { if (!cancelled) setTrailError(true); }); + return () => { cancelled = true; }; + }, [api, selectedId]); + + // The entry a grant belongs to is the one whose namespaced tools it carries. + // If the catalogue no longer has that entry, keep the row visibly unknown; + // assigning it to GitHub would misstate who owns the grant and its policy. + const entryFor = (grant: ToolGrant): ToolCatalogEntry | null => ( + catalog.find((entry) => entryCovers(entry, grant)) || null + ); + const noticeMatchesEntry = (entry: ToolCatalogEntry | null): boolean => Boolean( + manualRevokeNotice + && entry?.entryId === manualRevokeNotice.entryId + && entry.connections.some((connection) => connection.connectionId === manualRevokeNotice.connectionId), + ); + const toolLabel = (grant: ToolGrant): string => entryFor(grant)?.label + || t('tools.unknownConnector', { defaultValue: 'Unknown connector' }); + + const podName = (podId: string): string => pods.find((pod) => String(pod._id) === podId)?.name || t('tools.aPod', { defaultValue: 'a pod' }); + // A bot member takes its curated displayName, then a non-default instanceId, then its + // username. This client does not screen leaked runtime labels the way the server's + // resolveAgentDisplayLabel does, so a bot whose displayName is a runtime name shows it. + const memberName = (userId: string | null): string | null => { + if (!userId) return null; + for (const pod of pods) { + for (const member of pod.members || []) { + if (typeof member !== 'object' || !member || String(member._id) !== userId) continue; + const instanceId = member.botMetadata?.instanceId; + const botLabel = member.botMetadata?.displayName?.trim() + || (instanceId && instanceId !== 'default' ? instanceId : ''); + if (botLabel) return botLabel; + if (member.username) return member.username; + } + } + return null; + }; + const seatLabel = (podId: string | null, userId: string): string => { + const pool = podId ? (seats[podId] || []) : Object.values(seats).flat(); + const seat = pool.find((row) => row.userId === userId) || Object.values(seats).flat().find((row) => row.userId === userId); + if (seat) return seat.displayName || seat.name; + // `seats` is the agent list, and a pod grant's audience is a snapshot of the + // room's members — so a human in it resolved to nothing and the row read + // "an agent, an agent, ". Name the member instead of asserting an + // identity the server never claimed (TASK-050). + return memberName(userId) || t('tools.aSeat', { defaultValue: 'an agent' }); + }; + const grantPodId = (grant: ToolGrant): string | null => { + if (grant.target.kind === 'pod') return grant.target.id; + const entry = Object.entries(seats).find(([, rows]) => rows.some((row) => row.userId === grant.target.id)); + return entry ? entry[0] : null; + }; + // zh joins a list with 、 and en with ', ', so the separator is copy and not a + // literal at each join site (TASK-164). + const joinList = (items: string[]): string => items.join(t('tools.listSeparator', { defaultValue: ', ' })); + const audienceLabels = (grant: ToolGrant): string => { + const podId = grantPodId(grant); + const labels = grant.effectiveAudience.map((id) => seatLabel(podId, id)); + if (labels.length === 0) return t('tools.nobody', { defaultValue: 'no agent' }); + return joinList(labels); + }; + const irreversibleTools = (entry: ToolCatalogEntry | null, tools: string[]): string[] => (entry?.tools || []) + .filter((tool) => tool.irreversible && tools.includes(tool.name)).map((tool) => tool.name); + const asksFirst = (grant: ToolGrant): string => { + if (grant.writeMode === 'read') return t('tools.asksNothing', { defaultValue: 'nothing asks first' }); + if (grant.writeMode === 'write-with-confirm') return t('tools.asksEveryWrite', { defaultValue: 'every write asks first' }); + // Under `write` the floor is the tool's own irreversible flag (piece 2b): the list is the catalogue's. + const entry = entryFor(grant); + if (!entry) return t('tools.unknownWritePolicy', { defaultValue: 'write policy unavailable' }); + const list = irreversibleTools(entry, grant.tools); + return list.length + ? t('tools.asksList', { defaultValue: '{{tools}} ask first', tools: joinList(list) }) + : t('tools.asksNothing', { defaultValue: 'nothing asks first' }); + }; + const outcomeLabel = (outcome: ToolOutcome): string => ({ + ok: t('tools.outcomeOk', { defaultValue: 'ok' }), + refused: t('tools.outcomeRefused', { defaultValue: 'refused' }), + pending_approval: t('tools.awaiting', { defaultValue: 'awaiting a person' }), + superseded: t('tools.outcomeSuperseded', { defaultValue: 'answered' }), + failed: t('tools.outcomeFailed', { defaultValue: 'failed' }), + })[outcome] || outcome; + const modeLabel = (mode: GrantWriteMode): string => ({ + read: t('tools.modeRead', { defaultValue: 'read' }), + 'write-with-confirm': t('tools.modeWriteConfirm', { defaultValue: 'read and write, ask first' }), + write: t('tools.modeWrite', { defaultValue: 'read and write' }), + })[mode]; + + const entryGrantModes = (entry: ToolCatalogEntry): GrantWriteMode[] => { + if (entry.connectionType !== 'hosted-mcp') return WRITE_MODES; + return entry.tools.some((tool) => tool.requiredWriteMode !== 'read') + ? ['read', 'write-with-confirm'] + : ['read']; + }; + + const entryModeCopy = (entry: ToolCatalogEntry): string => { + const modes = entryGrantModes(entry); + return modes.length === 1 + ? modeLabel(modes[0]) + : t('tools.readOrWrite', { defaultValue: 'read, or read and write' }); + }; + + const usedRecently = (grant: ToolGrant): boolean => { + const at = lastUse[grant.grantId]; + return Boolean(at) && Date.now() - new Date(at as string).getTime() < USED_RECENTLY_MS; + }; + + const q = query.trim().toLowerCase(); + const selected = selectedId ? (grants || []).find((grant) => grant.grantId === selectedId) || null : null; + + const openDraft = (entry: ToolCatalogEntry, from?: ToolGrant) => { + const podId = from ? (grantPodId(from) || podIds[0] || '') : (podIds[0] || ''); + const modes = entryGrantModes(entry); + const agentIds = (seats[podId] || []).map((seat) => seat.userId).filter((id): id is string => Boolean(id)); + setDraft({ + entry, + podId, + connectionId: entry.connections[0]?.connectionId || '', + writeMode: from && modes.includes(from.writeMode) ? from.writeMode : modes[0] || 'read', + audience: from ? agentIds.filter((id) => from.effectiveAudience.includes(id)) : agentIds, + expiryDays: 7, + replaces: from && !isDead(from) ? from.grantId : null, + }); + setSelectedId(null); + setConfirmRevoke(null); + setError(null); + setJoinPolicyFixPodId(null); + setPolicyFixedPodId(null); + }; + const openGithubAppSetup = () => { + setGithubAppSetup({ installationId: '', owner: '', repo: '' }); + setDraft(null); + setSelectedId(null); + setConfirmRevoke(null); + setError(null); + }; + // A background catalogue read can fail or stop offering this connection. + // Keep the open draft (and any server refusal) visible in either case. The + // mint still checks ownership, connection state, tools, audience and pod policy. + const draftEntry = draft + ? catalog.find((entry) => entry.installableId === draft.entry.installableId) || draft.entry + : null; + const draftTools = (entry: ToolCatalogEntry | null, mode: GrantWriteMode): string[] => (entry?.tools || []) + .filter((tool) => MODE_RANK[tool.requiredWriteMode] <= MODE_RANK[mode]).map((tool) => tool.name); + + const selectedAgentIds = (draft ? seats[draft.podId] || [] : []) + .map((seat) => seat.userId) + .filter((id): id is string => typeof id === 'string' && Boolean(id) && draft?.audience.includes(id) === true); + + const submitDraft = async () => { + if (!draft || !draftEntry || selectedAgentIds.length === 0) return; + setBusy(true); + setError(null); + setJoinPolicyFixPodId(null); + setPolicyFixedPodId(null); + try { + const expiresAt = new Date(Date.now() + draft.expiryDays * 86_400_000).toISOString(); + // The mint's body, as routes/grants.ts takes it: the server names the broker + // and takes the installation from the Connection (#1677) — neither is the caller's. + const minted = await api.post<{ grantId?: string }>('/api/grants', { + connectionId: draft.connectionId, + target: { kind: 'pod', id: draft.podId }, + tools: draftTools(draftEntry, draft.writeMode), + writeMode: draft.writeMode, + audience: selectedAgentIds, + expiresAt, + }); + // A resolved request alone is not a mint receipt (for example, an HTML + // fallback or empty response). Keep the form and the old grant intact. + if (typeof minted?.grantId !== 'string' || !minted.grantId.trim()) { + setError(t('tools.grantError', { defaultValue: 'Could not grant it.' })); + return; + } + // Change access: a change is a new grant and a revoke of the old one, because `tools` is never + // widened in place. The mint is kept whatever happens next; a failed revoke is retried alone + // (0368992e), never by minting a third grant. + if (draft.replaces) { + const oldGrantId = draft.replaces; + const newGrantId = String(minted?.grantId || ''); + try { + await api.post(`/api/grants/${oldGrantId}/revoke`); + markRevoked(oldGrantId); + } catch { + setStaleAfterChange({ oldGrantId, newGrantId }); + setDraft(null); + setSelectedId(oldGrantId); + await load(); + return; + } + } + setDraft(null); + await load(); + } catch (err) { + const code = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data; + setError(code?.message || code?.error || t('tools.grantError', { defaultValue: 'Could not grant it.' })); + const creator = pods.find((pod) => pod._id === draft.podId)?.createdBy as { _id?: string } | string | undefined; + const owner = typeof creator === 'string' ? creator : creator?._id; + if (code?.error === 'outsider_grant_pod_joinable' && owner === viewerId) { + setJoinPolicyFixPodId(draft.podId); + } + } finally { + setBusy(false); + } + }; + + const fixJoinPolicy = async () => { + if (!draft || joinPolicyFixPodId !== draft.podId || busy) return; + setBusy(true); + try { + await api.patch(`/api/pods/${encodeURIComponent(draft.podId)}/join-policy`, { joinPolicy: 'invite-only' }); + setJoinPolicyFixPodId(null); + setPolicyFixedPodId(draft.podId); + setError(null); + } catch (requestError) { + const data = (requestError as { response?: { data?: { message?: string; error?: string } } })?.response?.data; + setError(data?.message || data?.error || t('tools.joinPolicyError', { defaultValue: 'Could not make this pod invite-only.' })); + } finally { + setBusy(false); + } + }; + + /** + * Connect a hosted-MCP entry (scope §4): the start route answers with the + * vendor's authorize URL and sets the flow's browser nonce as an httpOnly + * cookie on the SAME response. + * + * `withCredentials` is what keeps that cookie: the API is a different origin + * from the app, so without it the browser drops the `Set-Cookie` and every + * callback refuses `invalid_state` — a person consents at the vendor and comes + * back to a row that is still not connected. A backend test sets the cookie + * itself and cannot witness this, which is why it is asserted here. + */ + const connectHostedMcp = async (entry: ToolCatalogEntry) => { + if (busy || !entry.entryId) return; + const authorizationWindow = window.open('', '_blank'); + setBusy(true); + setError(null); + try { + const result = await api.post<{ authorizeUrl?: string }>( + `/api/integrations/connect/hosted-mcp/${encodeURIComponent(entry.entryId)}/start`, + {}, + { withCredentials: true }, + ); + if (!result.authorizeUrl) throw new Error('authorization URL was missing'); + if (authorizationWindow) { + authorizationWindow.opener = null; + authorizationWindow.location.assign(result.authorizeUrl); + } else { + window.location.assign(result.authorizeUrl); + } + } catch (err) { + authorizationWindow?.close(); + const data = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data; + setError(hostedMcpErrorNotice(t, data?.error)); + } finally { + setBusy(false); + } + }; + + const submitGithubAppSetup = async () => { + if (!githubAppSetup || !isAdmin) return; + const installationId = githubAppSetup.installationId.trim(); + const owner = githubAppSetup.owner.trim(); + const repo = githubAppSetup.repo.trim(); + if (!installationId || !owner || !repo) { + setError(t('tools.githubAppRequired', { defaultValue: 'Enter the installation ID, owner, and repository.' })); + return; + } + setBusy(true); + setError(null); + try { + const response = await api.post('/api/integrations/github-app', { installationId, owner, repo }); + const createdBy = response?.integration?.createdBy; + const createdById = typeof createdBy === 'string' + ? createdBy + : createdBy && typeof createdBy === 'object' && createdBy._id + ? String(createdBy._id) + : ''; + if (createdById && viewerId && createdById !== viewerId) { + setError(t('tools.githubAppOwnedByOther', { defaultValue: 'This GitHub App is already installed by another administrator.' })); + return; + } + setGithubAppSetup(null); + await load(); + } catch (err) { + const code = (err as { response?: { data?: { error?: string; message?: string } } })?.response?.data; + setError(code?.message || code?.error || t('tools.githubAppError', { defaultValue: 'Could not install the GitHub App.' })); + } finally { + setBusy(false); + } + }; + + const markRevoked = (grantId: string) => { + setRevokedHere((current) => ({ ...current, [grantId]: new Date().toISOString() })); + }; + + const retryStaleRevoke = async () => { + if (!staleAfterChange) return; + setBusy(true); + setError(null); + try { + await api.post(`/api/grants/${staleAfterChange.oldGrantId}/revoke`); + markRevoked(staleAfterChange.oldGrantId); + setStaleAfterChange(null); + await load(); + } catch { + setError(t('tools.revokeError', { defaultValue: 'Could not revoke the grant.' })); + } finally { + setBusy(false); + } + }; + + const revoke = async (grant: ToolGrant) => { + setBusy(true); + setError(null); + try { + await api.post(`/api/grants/${grant.grantId}/revoke`); + markRevoked(grant.grantId); + setConfirmRevoke(null); + await load(); + } catch { + setError(t('tools.revokeError', { defaultValue: 'Could not revoke the grant.' })); + } finally { + setBusy(false); + } + }; + + const removeHiddenConnection = async (connection: HiddenToolConnection) => { + setBusy(true); + setError(null); + try { + await api.del(`/api/integrations/${encodeURIComponent(connection.connectionId)}`); + setConfirmHiddenRemoval(null); + if (connection.revokePage) setRemovedHiddenNotice({ label: connection.label, revokePage: connection.revokePage }); + await load(); + } catch { + setError(t('tools.removeConnectionError', { defaultValue: 'Could not remove the connection.' })); + } finally { + setBusy(false); + } + }; + + // A live connection of an offered hosted connector. The removal verb is the + // same one the not-offered row uses; the server revokes the connection's grants + // as part of it, and answers `revokeAt` when the provider's own page still + // needs a visit. + const disconnect = async (entry: ToolCatalogEntry, connectionId: string) => { + setBusy(true); + setError(null); + try { + const res = await api.del<{ revokeAt?: string }>(`/api/integrations/${encodeURIComponent(connectionId)}`); + setConfirmDisconnect(null); + setDisconnectedHere((current) => [...current, connectionId]); + // The removal revokes every grant on the connection, so the page says so + // now rather than waiting on a re-read that may fail. + const ended = (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant)); + setRevokedHere((current) => ({ ...current, ...Object.fromEntries(ended.map((grant) => [grant.grantId, new Date().toISOString()])) })); + setRemovedHiddenNotice({ label: entry.label, revokePage: safeProviderRevokePage(res?.revokeAt) ? res.revokeAt : null }); + await load(); + } catch (err) { + // The route answers 502 with one of these codes when the grants are already + // revoked and the row is disconnected, and only the provider's own revoke + // failed. A bare 502 (ingress, a pod restarting) reached nothing, so it takes + // the plain error below and marks nothing revoked. + const response = (err as { response?: { status?: number; data?: { code?: string; revokeAt?: string } } })?.response; + const partial = response?.status === 502 + && (response.data?.code === 'provider_revoke_failed' || response.data?.code === 'provider_revoked_removal_incomplete'); + if (partial) { + const ended = (grants || []).filter((grant) => !isDead(grant) && entryCovers(entry, grant)); + setRevokedHere((current) => ({ ...current, ...Object.fromEntries(ended.map((grant) => [grant.grantId, new Date().toISOString()])) })); + setConfirmDisconnect(null); + setDisconnectedHere((current) => [...current, connectionId]); + await load(); + if (safeProviderRevokePage(response?.data?.revokeAt)) { + setRemovedHiddenNotice({ label: entry.label, revokePage: response?.data?.revokeAt as string }); + } else { + setError(t('tools.disconnectPartial', { defaultValue: 'Disconnected {{label}} here, but {{label}} did not confirm. Remove Commonly in its connected apps.', label: entry.label })); + } + } else { + setError(t('tools.disconnectError', { defaultValue: 'Could not disconnect {{label}}.', label: entry.label })); + } + } finally { + setBusy(false); + } + }; + + return { + manualRevokeNotice, + viewerId, + isGranter, + rawGrants, + setGrants, + catalog, + setCatalog, + hiddenConnections, + setHiddenConnections, + seats, + setSeats, + lastUse, + setLastUse, + trail, + setTrail, + trailError, + setTrailError, + selectedId, + setSelectedId, + draft, + setDraft, + githubAppSetup, + setGithubAppSetup, + staleAfterChange, + setStaleAfterChange, + confirmRevoke, + setConfirmRevoke, + confirmDisconnect, + setConfirmDisconnect, + revokedHere, + setRevokedHere, + confirmHiddenRemoval, + setConfirmHiddenRemoval, + removedHiddenNotice, + setRemovedHiddenNotice, + busy, + setBusy, + error, + setError, + joinPolicyFixPodId, + setJoinPolicyFixPodId, + policyFixedPodId, + setPolicyFixedPodId, + query, + setQuery, + grants, + now, + isAdmin, + podIds, + load, + entryFor, + noticeMatchesEntry, + toolLabel, + podName, + memberName, + seatLabel, + grantPodId, + joinList, + audienceLabels, + irreversibleTools, + asksFirst, + outcomeLabel, + modeLabel, + entryGrantModes, + entryModeCopy, + usedRecently, + q, + selected, + openDraft, + openGithubAppSetup, + draftEntry, + draftTools, + selectedAgentIds, + submitDraft, + fixJoinPolicy, + connectHostedMcp, + submitGithubAppSetup, + markRevoked, + retryStaleRevoke, + revoke, + removeHiddenConnection, + disconnect, + }; +}; + +export type ToolConnectorsModel = ReturnType; diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 8b06382fbe..0441b2e39f 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -10867,7 +10867,6 @@ body.modern-ui.v2-canvas { * ------------------------------------------------------------------------ */ .v2-root:lang(zh) .v2-rail__brand, .v2-root:lang(zh) .v2-board__detail .v2-modal__title, -.v2-root:lang(zh) .v2-tools__title, .v2-root:lang(zh) .v2-tools__count strong, .v2-root:lang(zh) .v2-artifacts__title, .v2-root:lang(zh) .v2-team__title, @@ -10888,7 +10887,6 @@ body.modern-ui.v2-canvas { .v2-root:lang(zh) .v2-settings__account-name, .v2-root:lang(zh) .v2-settings__section .v2-billing__title, .v2-root:lang(zh) .v2-settings__section .v2-devices h2, -.v2-root:lang(zh) .v2-connectors__header h1, .v2-root:lang(zh) .v2-connector-row__name, .v2-root:lang(zh) .v2-connector-aside__card h2, .v2-root:lang(zh) .v2-feature__legacy .MuiTypography-h1, @@ -10970,55 +10968,9 @@ body.modern-ui.v2-canvas { font-size: 12px; } -/* ── Connectors — Signal Coverage artboard ──────────────────────────────── */ -.v2-connectors { - display: flex; - flex-direction: column; - gap: 28px; -} -.v2-connectors__header h1 { - margin: 0; - color: var(--v2-ink); - font-family: var(--v2-font-display); - font-size: 32px; - font-weight: 800; - letter-spacing: -0.03em; - line-height: 36px; -} +/* ── Connectors: the page itself is connectors.css; what is left here is the + panels the drawer holds (rows, asides, gates). ──────────────────────────── */ .v2-connectors__loading { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; } -.v2-connectors__content { display: grid; grid-template-columns: minmax(min-content, 1fr) minmax(240px, 400px); gap: 40px; align-items: start; } -.v2-connectors__main { display: flex; min-width: 0; min-height: 0; flex-direction: column; } -.v2-connectors__rows { overflow: hidden; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius-sm); } -.v2-connectors__inactive-fold { min-width: 0; } -.v2-root button.v2-connectors__inactive-toggle { display: flex; align-items: center; gap: 8px; width: 100%; min-height: 44px; padding: 10px 18px; border: 0; background: var(--v2-surface); color: var(--v2-text-tertiary); font: 500 12px/16px var(--v2-font-mono); text-align: left; cursor: pointer; } -.v2-root button.v2-connectors__inactive-toggle:hover { background: var(--v2-surface-hover); } -.v2-root button.v2-connectors__inactive-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--v2-accent); outline: none; } -.v2-connectors__inactive-caret { width: 7px; height: 7px; border-right: 1px solid currentColor; border-bottom: 1px solid currentColor; transform: rotate(-45deg); } -.v2-root button.v2-connectors__inactive-toggle[aria-expanded="true"] .v2-connectors__inactive-caret { transform: rotate(45deg); } -.v2-connectors__inactive-rows > .v2-connector-row:last-child { border-bottom: 0; } -/* TASK-677 slice (a), D215 artboard: channel rows render as a tile grid - (auto-fill, 150px minimum, per the 2026-10-31 ruling; the aside stays). Layout - only: the same rows, the same act and the same connect flow. Tools rows are not - tiled in this slice. The inactive fold spans the grid, and its own rows reuse - the same tile template. */ -.v2-connectors__rows { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; overflow: visible; border: 0; border-radius: 0; } -.v2-connectors__rows .v2-connector-row { grid-template-columns: minmax(0, 1fr); align-content: center; min-height: 128px; border: 1px solid var(--v2-border-soft); border-radius: 14px; padding: 14px 12px; } -.v2-connectors__inactive-fold { grid-column: 1 / -1; } -.v2-connectors__inactive-rows { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 12px 0 0; } -/* Lumen's 1440 HOLD (review 5722362448): the row's own two-track grid and its - spanning placements sized the name, detail and act past a 150px tile. Inside a - tile the content stacks in one track and wraps within the tile. */ -.v2-root .v2-connectors__rows button.v2-connector-row__selection { grid-column: 1; grid-template-columns: minmax(0, 1fr); row-gap: 6px; } -.v2-connectors__rows .v2-connector-row__name { min-width: 0; overflow-wrap: anywhere; flex-direction: column; align-items: center; gap: 6px; text-align: center; } -.v2-connectors__rows .v2-connector-row__details { min-width: 0; overflow-wrap: anywhere; padding-right: 0; } -/* The 760px rule reserved 52px beside the icon act (column 2). Inside a tile the - icon act is centred under the text, so the reserve only narrowed the text. The - specificity here is (0,5,1), above the 760px padding-right rule (0,4,1). */ -.v2-root .v2-connectors__rows .v2-connector-row:has(.v2-connector-row__action--icon) button.v2-connector-row__selection { padding-right: 0; } -.v2-root .v2-connectors__rows .v2-connector-row__action { grid-column: 1; grid-row: 2; justify-self: stretch; } -/* The icon act is 32px or 44px wide, so stretch does nothing; centre it under the - text. Specificity (0,4,1) beats the 760px icon placement (0,2,1). */ -.v2-root .v2-connectors__rows button.v2-connector-row__action--icon { grid-column: 1; grid-row: 2; justify-self: center; } .v2-connector-row { display: grid; @@ -11041,7 +10993,6 @@ body.modern-ui.v2-canvas { } .v2-connector-row:last-child { border-bottom: none; } .v2-connector-row--selected { background: var(--v2-surface-tint); } -.v2-connector-row--not-yet { color: var(--v2-text-tertiary); } .v2-root button.v2-connector-row__selection { display: grid; grid-column: 1 / 3; @@ -11058,19 +11009,14 @@ body.modern-ui.v2-canvas { cursor: pointer; } .v2-connector-row__name { display: inline-flex; align-items: center; gap: 10px; color: var(--v2-ink); font-family: var(--v2-font-display); font-size: 18px; font-weight: 800; letter-spacing: -0.02em; } -.v2-connector-row--not-yet .v2-connector-row__name { color: var(--v2-text-tertiary); } .v2-connector-row__dot { width: 8px; height: 8px; flex: none; border-radius: var(--v2-radius-sm); background: var(--v2-border-soft); } .v2-connector-row__glyph { display: inline-flex; width: 20px; height: 20px; flex: 0 0 20px; color: inherit; } .v2-connector-row__glyph svg { width: 20px; height: 20px; } -.v2-connector-row--not-yet .v2-connector-row__glyph { color: var(--v2-text-placeholder); } /* Direction A: the state dot is a mark — ink filled, hollow, dashed, grey — never cobalt (cobalt is who asks) and never a status colour (ux-lead 70159). */ .v2-connector-row__dot--live { background: var(--v2-ink); } -.v2-connector-row__dot--pending { border: 1px solid var(--v2-border-strong); background: transparent; } -.v2-connector-row--dead .v2-connector-row__dot, .v2-connector-row__dot--dead { background: var(--v2-text-placeholder); } +.v2-connector-row--dead .v2-connector-row__dot { background: var(--v2-text-placeholder); } .v2-connector-row__dot--empty { border: 1px solid var(--v2-border-strong); background: transparent; } -.v2-connector-row__dot--idle { background: var(--v2-border-soft); } -.v2-connector-row__dot--not-yet { border: 1px dashed var(--v2-border-strong); background: transparent; } .v2-connector-row__dot--pulse { animation: v2-connector-pulse 1.6s ease-in-out infinite; } @keyframes v2-connector-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(16, 24, 40, 0); } @@ -11082,26 +11028,9 @@ body.modern-ui.v2-canvas { .v2-connector-row__details { display: flex; min-width: 0; padding-right: 8px; flex-direction: column; gap: 2px; color: var(--v2-text-secondary); font-size: 13px; line-height: 18px; } /* Line 1 wraps like meta does; a sentence never ellipsises at 390. */ .v2-connector-row__details strong { color: var(--v2-ink); font-size: 14px; font-weight: 600; overflow-wrap: anywhere; } -.v2-connector-row__detail, .v2-connector-row__when { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } +.v2-connector-row__detail { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } /* Meta wraps to a second line; it never ellipsises. */ .v2-connector-row__detail { overflow-wrap: anywhere; } -/* TASK-162 (3): line 3 used to be the bare 16px mark, its consequence words - reachable only through the glyph's tooltip — and at ≤760 the mark is hidden, - so line 3 disappeared entirely on a phone. The consequence is now TEXT beside - the mark; the mode word moved onto the mark itself (title + aria-label), and - the glyph follows #1782 — it takes the Tools glyph's rule below for layout and - colour (gap 6, tertiary, vertical-align -2px) rather than a rule of its own. - That rule carries NO dimensions: the 14px is `size={14}` on the call site, - held equal to the Tools glyph's own hardcoded 14 (V2ConnectorTools' local `G`) - by a same-size pin in V2ConnectorsPage.test.tsx (TASK-177). 16px would grow the - line box to 18px beside a 12/16 mono sentence, which is why it is 14 and not - the glyph's 16 default. */ -/* TASK-162 (2): the not-yet row lists two channels. Above 760 they stack in the - 140px name track; at ≤760 they join with ' · ' (the separator is the only - element that switches, so nothing here depends on generated content). */ -.v2-connector-row__names { display: flex; flex-direction: column; } -.v2-connector-row__name-sep { display: none; } -.v2-connector-row__when { padding-right: 16px; } /* Direction A rule 3: `pod · verb age` in mono 11 above line 1, on every row. */ .v2-connector-row__kicker { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; font-weight: 500; line-height: 16px; text-transform: lowercase; overflow-wrap: anywhere; } /* The mode word rides the kicker only where the glyph's tooltip cannot (≤760). */ @@ -11110,7 +11039,7 @@ body.modern-ui.v2-canvas { the sentence in title + aria-label — and the mark shares the Tools glyph's rule (search `.v2-connector-row__mark, .v2-tools__mode`) for layout and colour. That sharing does NOT carry the size: the two agree at 14px through two independent - literals in two files, so a same-size pin in V2ConnectorsPage.test.tsx is what + literals in two files, so a same-size pin in ConnectorsPage.tools.test.tsx is what keeps them from drifting (TASK-177). */ .v2-root button.v2-connector-row__action, .v2-root a.v2-connector-row__action { @@ -11151,9 +11080,6 @@ body.modern-ui.v2-canvas { min-height is what actually releases the 36px clamp. */ .v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon { display: inline-grid; place-items: center; width: 32px; height: 32px; min-height: 32px; padding: 0; } .v2-root button.v2-connector-aside__icon svg { width: 16px; height: 16px; } - -.v2-connectors__add { margin-top: 24px; } -.v2-root button.v2-connectors__connect, .v2-root button.v2-connectors__create, .v2-root button.v2-connector-aside__primary { min-height: 40px; @@ -11165,12 +11091,10 @@ body.modern-ui.v2-canvas { font: 600 13px/18px var(--v2-font); cursor: pointer; } -.v2-root button.v2-connectors__connect:hover, .v2-root button.v2-connectors__create:hover:not(:disabled), .v2-root button.v2-connector-aside__primary:hover:not(:disabled) { background: var(--v2-ink-hover); } .v2-root button.v2-connectors__create:disabled, .v2-root button.v2-connector-aside__primary:disabled { cursor: progress; opacity: 0.65; } -.v2-connectors__add > p { margin: 8px 0 0; color: var(--v2-text-tertiary); font-size: 13px; } .v2-connectors__new-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, 240px) auto; gap: 10px; margin-top: 16px; } .v2-connectors__new-row--aside { grid-template-columns: minmax(0, 1fr) auto; } .v2-connectors__new-row--aside .v2-connectors__select { grid-column: 1 / -1; } @@ -11214,7 +11138,6 @@ body.modern-ui.v2-canvas { .v2-connector-row__refusal { grid-column: 1 / -1; margin: 10px 0 0; padding: 8px 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface-tint); color: var(--v2-ink); font-size: 13px; line-height: 18px; overflow-wrap: anywhere; } .v2-connector-row__refusal a, .v2-connector-aside__refusal a { color: var(--v2-accent-text); text-decoration: underline; } -.v2-connectors__error { padding: 10px 12px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); color: var(--v2-ink); font-size: 13px; } .v2-connector-aside__note { margin-top: 12px !important; color: var(--v2-text-tertiary) !important; font-size: 12px !important; } /* Row C: the refusal the row's own button earned, beside that button in the @@ -11241,18 +11164,6 @@ body.modern-ui.v2-canvas { .v2-connector-gate__switch:disabled { cursor: progress; opacity: 0.65; } .v2-connector-gate__more { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 4px 0 12px 12px; } .v2-connector-gate__lead { min-height: 30px; padding: 0 8px; font-size: 12px; } - -/* Tools — the second list (tools plan §6). Rows reuse .v2-connector-row; the - aside reuses the connector aside cards. Outcomes are words in muted mono, - never a coloured word. A list card is as tall as its rows (TASK-156): neither - list stretches, so the page scrolls instead of a card holding empty space. */ -.v2-tools__head { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; } -.v2-tools__title { margin: 0; color: var(--v2-ink); font-family: var(--v2-font-display); font-size: 20px; font-weight: 800; letter-spacing: -0.02em; } -.v2-tools__count-line { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } -.v2-tools__search { min-width: 0; flex: 1 1 200px; min-height: 32px; margin-left: auto; padding: 0 10px; border: 1px solid var(--v2-border); border-radius: var(--v2-radius-sm); background: var(--v2-surface); color: var(--v2-ink); font: 500 13px var(--v2-font); } -.v2-tools__search:focus-visible { outline: 2px solid var(--v2-accent); outline-offset: 0; } -.v2-tools__segment { flex: none; } -.v2-tools__empty { margin: 0; padding: 14px 16px; color: var(--v2-text-tertiary); font-size: 13px; } .v2-tools__facts { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2px; margin: 14px 0 0; } .v2-tools__facts dt { margin-top: 8px; color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; text-transform: lowercase; } .v2-tools__facts dt:first-child { margin-top: 0; } @@ -11267,7 +11178,8 @@ body.modern-ui.v2-canvas { .v2-tools__trail-line { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid var(--v2-border-soft); color: var(--v2-text-muted); font-family: var(--v2-font-mono); font-size: 12px; line-height: 16px; } .v2-tools__trail-line > span:first-child { min-width: 0; overflow-wrap: anywhere; } .v2-tools__trail-when { flex: none; } -.v2-tools__mode, .v2-tools__outcome, .v2-connector-row__mark { display: inline-flex; align-items: center; margin-right: 6px; vertical-align: -2px; color: var(--v2-text-tertiary); } +.v2-tools__mode, +.v2-tools__outcome { display: inline-flex; align-items: center; margin-right: 6px; vertical-align: -2px; color: var(--v2-text-tertiary); } .v2-tools__form { display: grid; gap: 14px; margin-top: 14px; } .v2-tools__field { display: grid; gap: 6px; margin: 0; padding: 0; border: none; min-width: 0; } .v2-tools__field > span:first-child, .v2-tools__field > legend { color: var(--v2-text-tertiary); font-family: var(--v2-font-mono); font-size: 11px; line-height: 16px; text-transform: lowercase; padding: 0; } @@ -11276,24 +11188,15 @@ body.modern-ui.v2-canvas { .v2-tools__agents { display: grid; gap: 6px; } .v2-tools__hint { color: var(--v2-text-secondary); font-size: 12px; line-height: 18px; } .v2-tools__hint code { display: inline-block; margin: 0 6px 4px 0; padding: 1px 6px; border: 1px solid var(--v2-border-soft); border-radius: var(--v2-radius-sm); font-family: var(--v2-font-mono); font-size: 12px; white-space: nowrap; } -@media (max-width: 1010px) { - .v2-connectors__content { grid-template-columns: minmax(0, 1fr); gap: 24px; } -} @media (max-width: 760px) { - .v2-connectors { gap: 24px; margin: -12px -18px 0; } - .v2-connectors__content { grid-template-columns: minmax(0, 1fr); gap: 24px; } .v2-connector-row { grid-template-columns: minmax(0, 1fr) auto; min-height: 0; padding: 14px 12px; } .v2-root button.v2-connector-row__selection { grid-column: 1 / -1; grid-template-columns: minmax(0, 1fr) auto; row-gap: 4px; } .v2-root button.v2-connector-row__selection .v2-connector-row__name { grid-column: 1; grid-row: 1; } .v2-root button.v2-connector-row__selection .v2-connector-row__details { grid-column: 1 / -1; grid-row: 2; } - .v2-root button.v2-connector-row__selection .v2-connector-row__when { grid-column: 1 / -1; grid-row: 3; padding-right: 0; } - .v2-connector-row--not-yet .v2-connector-row__name { grid-column: 1; grid-row: 1; } - .v2-connector-row--not-yet .v2-connector-row__details { grid-column: 1 / -1; grid-row: 2; } - .v2-connector-row--not-yet .v2-connector-row__when { grid-column: 1 / -1; grid-row: 3; padding-right: 0; } /* A phone keeps the row's REASON. This used to be a blanket hide with enumerated exceptions (`:not(.v2-connector-row--dead):not(.v2-connector-row--not-enabled)`), which silently swallowed every state the list had not anticipated: the - not-yet row that IS available (V2ConnectorTools.tsx:562) lost + not-yet row that IS available (ToolPanels.tsx) lost "install the GitHub App first" / "read, or read and write", and a stranger saw a "not granted" kicker with no reason under it (eng lead 73726). Default is now SHOW; if one row ever needs its detail hidden on phones, hide @@ -11305,9 +11208,6 @@ body.modern-ui.v2-canvas { .v2-connector-row { min-height: 72px; } .v2-root button.v2-connector-row__selection { grid-row: 1; } .v2-connector-row__kicker-mode { display: inline; } - .v2-connector-row__mark { display: none; } - .v2-connector-row__names { display: block; } - .v2-connector-row__name-sep { display: inline; } .v2-root button.v2-connector-row__action--icon { width: 44px; min-height: 44px; grid-column: 2; grid-row: 1; justify-self: end; align-self: start; } .v2-root button.v2-connector-row__action:not(.v2-connector-row__action--icon), .v2-root a.v2-connector-row__action { display: inline-flex; grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: center; min-height: 44px; margin-top: 8px; } /* The gear sits over the selection button's top-right; keep the kicker out from under it. */ @@ -11317,8 +11217,6 @@ body.modern-ui.v2-canvas { .v2-connectors__new-row { grid-template-columns: minmax(0, 1fr); } .v2-connectors__providers { width: fit-content; } .v2-connector-gate__more { padding-left: 0; } - .v2-tools__head { gap: 8px; } - .v2-tools__search { flex-basis: 100%; margin-left: 0; } } /* Activity queue: reply-in-place on mention rows (2026-09-01). The