From 79a59e3ffdedaaa0036d7516c440df6428a81851 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:28:40 -0700 Subject: [PATCH 1/8] fix(connectors): Revoke updates the card in place, and a live connection can be disconnected (TASK-733) Two gaps from the Atlassian walk. Revoke: the page re-read the grants and trusted the answer, so a stale or failed read left the card saying "granted", and a failed read blanked the whole list. A confirmed revoke is now final on the page (revocation is irreversible), and a failed read keeps what was on screen. Disconnect: only a connection that was no longer offered got "Remove connection". A live connection of an offered hosted connector now has Disconnect (two clicks) on its not-granted row and in its grant card. It uses the same DELETE /api/integrations/:id, which already revokes the connection's grants, and links the provider page when the server returns one. GitHub's instance App connection is not offered it. A bug fix on the current page; the rebuild carries both tests. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- frontend/src/i18n/locales/en.json | 5 ++ frontend/src/i18n/locales/zh-CN.json | 5 ++ .../v2/__tests__/V2ConnectorTools.test.tsx | 86 ++++++++++++++++++- .../src/v2/components/V2ConnectorTools.tsx | 78 ++++++++++++++++- .../src/v2/components/V2ConnectorsPage.tsx | 11 +-- .../src/v2/utils/safeProviderRevokePage.ts | 10 +++ 6 files changed, 180 insertions(+), 15 deletions(-) create mode 100644 frontend/src/v2/utils/safeProviderRevokePage.ts diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9f4722335c..037e5279c8 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2549,6 +2549,11 @@ "removeConnection": "Remove connection", "confirmRemoveConnection": "Yes, remove", "removeConnectionError": "Could not remove the connection.", + "disconnect": "Disconnect", + "disconnectLabel": "Disconnect {{label}}", + "confirmDisconnect": "Yes, disconnect", + "disconnectError": "Could not disconnect {{label}}.", + "disconnected": "Disconnected {{label}}.", "reviewProviderAccess": "Review access at {{label}}", "removedReviewProvider": "Connection removed. Review its access at {{label}}.", "openProviderSettings": "Open provider settings", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 86754ad1e5..c64c47846b 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2552,6 +2552,11 @@ "removeConnection": "移除连接", "confirmRemoveConnection": "确认移除", "removeConnectionError": "无法移除连接。", + "disconnect": "断开连接", + "disconnectLabel": "断开 {{label}}", + "confirmDisconnect": "确认断开", + "disconnectError": "无法断开 {{label}}。", + "disconnected": "已断开 {{label}}。", "reviewProviderAccess": "查看 {{label}} 的授权", "removedReviewProvider": "连接已移除。请检查 {{label}} 中的授权。", "openProviderSettings": "打开服务提供方设置", diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index c9a5f6e395..cd36e897d2 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -516,6 +516,89 @@ test('Revoke is two-click and posts the revoke verb, then reloads; a dead grant 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)); + 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 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 failed disconnect says so and keeps the connection', async () => { + mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } }); + axios.delete.mockRejectedValue(new Error('502')); + 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(); + }); + + 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. @@ -663,7 +746,8 @@ test('Change access mints a new grant then revokes the old one; Grant again on a expect(axios.post.mock.calls[1][0]).toBe('/api/grants/grant_live/revoke'); axios.post.mockClear(); - fireEvent.click(screen.getByRole('button', { name: 'Grant again' })); + // 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' })); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index a1eda32389..ec5147074f 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -21,6 +21,7 @@ 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'; @@ -214,7 +215,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) const { currentUser } = useAuth(); const viewerId = currentUser?._id ? String(currentUser._id) : ''; const isGranter = (grant: ToolGrant): boolean => Boolean(viewerId) && grant.grantedBy === viewerId; - const [grants, setGrants] = useState(null); + const [rawGrants, setGrants] = useState(null); const [catalog, setCatalog] = useState([]); const [hiddenConnections, setHiddenConnections] = useState([]); const [seats, setSeats] = useState>({}); @@ -227,13 +228,23 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = 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); + 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 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(); @@ -254,7 +265,8 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) })), ]); const ownedGrants = grantRes?.grants || []; - setGrants(ownedGrants); + // 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) => { @@ -501,6 +513,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) const newGrantId = String(minted?.grantId || ''); try { await api.post(`/api/grants/${oldGrantId}/revoke`); + markRevoked(oldGrantId); } catch { setStaleAfterChange({ oldGrantId, newGrantId }); setDraft(null); @@ -611,12 +624,17 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) } }; + 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 { @@ -631,6 +649,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) setError(null); try { await api.post(`/api/grants/${grant.grantId}/revoke`); + markRevoked(grant.grantId); setConfirmRevoke(null); await load(); } catch { @@ -655,6 +674,51 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) } }; + // 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); + setRemovedHiddenNotice({ label: entry.label, revokePage: safeProviderRevokePage(res?.revokeAt) ? res.revokeAt : null }); + await load(); + } catch { + 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') => { + const connection = entry && entry.connectionType === 'hosted-mcp' ? 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) => (
@@ -841,6 +905,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) {t('tools.add', { defaultValue: 'Add' })} )} + {renderDisconnect(entry, 'row')} {canInstallGithubApp && ( ))} + {isGranter(grant) && ( +
{renderDisconnect(entry, 'aside')}
+ )} {error &&

{error}

}
@@ -1132,7 +1200,9 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) {error && !draft && !selected && !githubAppSetup && (

{error}

)} - {removedHiddenNotice &&

{t('tools.removedReviewProvider', { defaultValue: 'Connection removed. Review its access at {{label}}.', label: removedHiddenNotice.label })} {t('tools.openProviderSettings', { defaultValue: 'Open provider settings' })}

} + {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)} diff --git a/frontend/src/v2/components/V2ConnectorsPage.tsx b/frontend/src/v2/components/V2ConnectorsPage.tsx index f97ac0d285..43b683848a 100644 --- a/frontend/src/v2/components/V2ConnectorsPage.tsx +++ b/frontend/src/v2/components/V2ConnectorsPage.tsx @@ -16,6 +16,7 @@ import V2ConnectorTools, { type HostedMcpManualRevokeNotice } from './V2Connecto 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'; @@ -276,16 +277,6 @@ const claimIsStale = (installation: CatalogInstallation): boolean => { return Date.now() - claimedAt >= INSTALL_LOCK_TTL_MS; }; -const safeProviderRevokePage = (value: unknown): value is string => { - if (typeof value !== 'string') return false; - try { - const url = new URL(value); - return url.protocol === 'https:' && !url.username && !url.password; - } catch { - return false; - } -}; - const projectionMissing = (entry: CatalogEntry): boolean => ( !entry.integration || entry.integration.isActive === false diff --git a/frontend/src/v2/utils/safeProviderRevokePage.ts b/frontend/src/v2/utils/safeProviderRevokePage.ts new file mode 100644 index 0000000000..c3c002e563 --- /dev/null +++ b/frontend/src/v2/utils/safeProviderRevokePage.ts @@ -0,0 +1,10 @@ +// A provider's revoke page is a link we render, so only a plain https URL qualifies. +export const safeProviderRevokePage = (value: unknown): value is string => { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && !url.username && !url.password; + } catch { + return false; + } +}; From dc8e87d6909c8726b04ab2ecb093702f2e7c2ec6 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:36:02 -0700 Subject: [PATCH 2/8] fix(connectors): a partial disconnect shows the grants revoked and links the provider page (TASK-733) Review of #3135: a 502 from DELETE /api/integrations/:id means the grants are already revoked and the row is disconnected, only the provider's revoke failed. The catch now marks those grants revoked, reloads and links the revokeAt page the body names. A successful disconnect marks the connection's grants revoked at once too. Disconnect is offered only when the entry has one connection, since a grant does not say which connection it came from. Tests: the 502 case, the two- connection case, and the revoke test now reaches its failing re-read. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../v2/__tests__/V2ConnectorTools.test.tsx | 31 +++++++++++++++++-- .../src/v2/components/V2ConnectorTools.tsx | 23 ++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index cd36e897d2..de41ccb1d3 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -541,6 +541,9 @@ test('after Revoke the grant card says revoked in place, even when the re-read i 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(); @@ -582,9 +585,9 @@ describe('Disconnect on a live connection of an offered hosted connector', () => expect(link).toHaveAttribute('href', 'https://id.atlassian.com/manage-profile/apps'); }); - test('a failed disconnect says so and keeps the connection', async () => { + test('a network failure says so and keeps the connection', async () => { mockTwoEntries({ catalog: [atlassian], grants: { p1: [], p2: [] } }); - axios.delete.mockRejectedValue(new Error('502')); + axios.delete.mockRejectedValue(new Error('network')); renderTools(); fireEvent.click(await screen.findByRole('button', { name: 'Disconnect Atlassian' })); fireEvent.click(screen.getByRole('button', { name: 'Yes, disconnect' })); @@ -592,6 +595,30 @@ describe('Disconnect on a live connection of an offered hosted connector', () => 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(); + }); + + 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' }); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index ec5147074f..094fdb59ce 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -684,9 +684,26 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) try { const res = await api.del<{ revokeAt?: string }>(`/api/integrations/${encodeURIComponent(connectionId)}`); setConfirmDisconnect(null); + // 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 { + } catch (err) { + // A 502 is a partial removal: the grants are already revoked and the row is + // disconnected, only the provider's own revoke failed. Show that, and send + // the person to the provider page the server named. + const data = (err as { response?: { status?: number; data?: { revokeAt?: string } } })?.response; + if (data?.status === 502) { + 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); + if (safeProviderRevokePage(data.data?.revokeAt)) { + setRemovedHiddenNotice({ label: entry.label, revokePage: data.data.revokeAt }); + } + await load(); + } setError(t('tools.disconnectError', { defaultValue: 'Could not disconnect {{label}}.', label: entry.label })); } finally { setBusy(false); @@ -695,7 +712,9 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) // 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') => { - const connection = entry && entry.connectionType === 'hosted-mcp' ? entry.connections[0] : null; + // 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' From 710d6e64e52585a627ef8b315baf389c05f84fff Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:37:15 -0700 Subject: [PATCH 3/8] fix(connectors): only the route's own 502 codes count as a partial disconnect (TASK-733) A bare 502 from the ingress reached nothing, so it must not mark grants revoked. Partial removal now needs provider_revoke_failed or provider_revoked_removal_incomplete, and says "disconnected here, finish at the provider" instead of two contradicting lines. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 1 + .../v2/__tests__/V2ConnectorTools.test.tsx | 14 +++++++++++ .../src/v2/components/V2ConnectorTools.tsx | 24 ++++++++++++------- 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 037e5279c8..9c321144bc 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2553,6 +2553,7 @@ "disconnectLabel": "Disconnect {{label}}", "confirmDisconnect": "Yes, disconnect", "disconnectError": "Could not disconnect {{label}}.", + "disconnectPartial": "Disconnected {{label}} here, but {{label}} did not confirm. Remove Commonly in its connected apps.", "disconnected": "Disconnected {{label}}.", "reviewProviderAccess": "Review access at {{label}}", "removedReviewProvider": "Connection removed. Review its access at {{label}}.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index c64c47846b..09f2032bd3 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2556,6 +2556,7 @@ "disconnectLabel": "断开 {{label}}", "confirmDisconnect": "确认断开", "disconnectError": "无法断开 {{label}}。", + "disconnectPartial": "已在此处断开 {{label}},但 {{label}} 未确认。请在其已连接的应用中移除 Commonly。", "disconnected": "已断开 {{label}}。", "reviewProviderAccess": "查看 {{label}} 的授权", "removedReviewProvider": "连接已移除。请检查 {{label}} 中的授权。", diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index de41ccb1d3..481202bb20 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -611,6 +611,20 @@ describe('Disconnect on a live connection of an offered hosted connector', () => 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: [] } }); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 094fdb59ce..0aac2ffefc 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -691,20 +691,26 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) setRemovedHiddenNotice({ label: entry.label, revokePage: safeProviderRevokePage(res?.revokeAt) ? res.revokeAt : null }); await load(); } catch (err) { - // A 502 is a partial removal: the grants are already revoked and the row is - // disconnected, only the provider's own revoke failed. Show that, and send - // the person to the provider page the server named. - const data = (err as { response?: { status?: number; data?: { revokeAt?: string } } })?.response; - if (data?.status === 502) { + // 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); - if (safeProviderRevokePage(data.data?.revokeAt)) { - setRemovedHiddenNotice({ label: entry.label, revokePage: data.data.revokeAt }); - } 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 })); } - setError(t('tools.disconnectError', { defaultValue: 'Could not disconnect {{label}}.', label: entry.label })); } finally { setBusy(false); } From 144ceacca61ead6d772b5401920c3d1f5edfa3dc Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:38:33 -0700 Subject: [PATCH 4/8] fix(connectors): a confirmed disconnect stays out of stale catalogue reads (TASK-733) Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- frontend/src/v2/__tests__/V2ConnectorTools.test.tsx | 12 ++++++++++++ frontend/src/v2/components/V2ConnectorTools.tsx | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx index 481202bb20..50024d7a78 100644 --- a/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx +++ b/frontend/src/v2/__tests__/V2ConnectorTools.test.tsx @@ -572,6 +572,18 @@ describe('Disconnect on a live connection of an offered hosted connector', () => 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' } }); diff --git a/frontend/src/v2/components/V2ConnectorTools.tsx b/frontend/src/v2/components/V2ConnectorTools.tsx index 0aac2ffefc..c8d848d67f 100644 --- a/frontend/src/v2/components/V2ConnectorTools.tsx +++ b/frontend/src/v2/components/V2ConnectorTools.tsx @@ -216,7 +216,9 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) const viewerId = currentUser?._id ? String(currentUser._id) : ''; const isGranter = (grant: ToolGrant): boolean => Boolean(viewerId) && grant.grantedBy === viewerId; const [rawGrants, setGrants] = useState(null); - const [catalog, setCatalog] = useState([]); + 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>({}); @@ -240,6 +242,10 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = 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) } @@ -684,6 +690,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = 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)); @@ -702,6 +709,7 @@ const V2ConnectorTools: React.FC = ({ pods, manualRevokeNotice = null }) 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 }); From 3b936315e876951bd2b1f7bb635204ba9b149edc Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:49:23 -0700 Subject: [PATCH 5/8] feat(connectors): rebuild the Connectors page as a logo grid from the d215 frame (TASK-733) One grid for channels and tools: the logo carries the name, state is a corner mark, descriptions live in a popover, and what is not offered folds into "+N off". A connected tile opens a drawer that holds the existing channel and grant panels, verbs and copy unchanged. The channel and tool logic moved into hooks (useChannelConnectors, useToolConnectors) and panels so the page is composition only. The route now renders the new page. The old page and its tests are retargeted in the next commit. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- frontend/src/i18n/locales/en.json | 14 + frontend/src/i18n/locales/zh-CN.json | 14 + frontend/src/v2/V2App.tsx | 5 +- frontend/src/v2/connectors/ChannelPanel.tsx | 388 ++++++ .../src/v2/connectors/ConnectorDrawer.tsx | 41 + frontend/src/v2/connectors/ConnectorLogo.tsx | 77 ++ .../src/v2/connectors/ConnectorPopover.tsx | 68 ++ frontend/src/v2/connectors/ConnectorTile.tsx | 69 ++ frontend/src/v2/connectors/ConnectorsPage.tsx | 483 ++++++++ frontend/src/v2/connectors/ToolPanels.tsx | 613 ++++++++++ frontend/src/v2/connectors/channelModel.ts | 274 +++++ frontend/src/v2/connectors/connectors.css | 122 ++ frontend/src/v2/connectors/toolModel.ts | 159 +++ .../src/v2/connectors/useChannelConnectors.ts | 1073 +++++++++++++++++ .../src/v2/connectors/useToolConnectors.ts | 615 ++++++++++ 15 files changed, 4013 insertions(+), 2 deletions(-) create mode 100644 frontend/src/v2/connectors/ChannelPanel.tsx create mode 100644 frontend/src/v2/connectors/ConnectorDrawer.tsx create mode 100644 frontend/src/v2/connectors/ConnectorLogo.tsx create mode 100644 frontend/src/v2/connectors/ConnectorPopover.tsx create mode 100644 frontend/src/v2/connectors/ConnectorTile.tsx create mode 100644 frontend/src/v2/connectors/ConnectorsPage.tsx create mode 100644 frontend/src/v2/connectors/ToolPanels.tsx create mode 100644 frontend/src/v2/connectors/channelModel.ts create mode 100644 frontend/src/v2/connectors/connectors.css create mode 100644 frontend/src/v2/connectors/toolModel.ts create mode 100644 frontend/src/v2/connectors/useChannelConnectors.ts create mode 100644 frontend/src/v2/connectors/useToolConnectors.ts diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 9c321144bc..40386c3f6b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2271,6 +2271,17 @@ } }, "connectors": { + "search": "Search", + "filterLabel": "Show", + "channelsGroup": "Channels", + "addChannelTile": "Channel", + "meta": "{{on}} on · {{available}} available", + "offCount": "+{{n}} off", + "filter": { + "all": "All", + "channels": "Channels", + "tools": "Tools" + }, "about": "About connectors", "counts": { "connected_one": "{{count}} connected", @@ -2479,6 +2490,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.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 09f2032bd3..efea0acc25 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2261,6 +2261,17 @@ } }, "connectors": { + "search": "搜索", + "filterLabel": "显示", + "channelsGroup": "频道", + "addChannelTile": "频道", + "meta": "{{on}} 个已开启 · {{available}} 个可用", + "offCount": "另有 {{n}} 个未开启", + "filter": { + "all": "全部", + "channels": "频道", + "tools": "工具" + }, "about": "关于连接器", "counts": { "connected_one": "已连接{{count}}个频道", @@ -2482,6 +2493,9 @@ "loadFailed": "无法加载产物。" }, "tools": { + "granted": "已授权", + "beta": "测试版", + "adminOnce": "需管理员安装一次", "emptyAudienceNotice": "请至少选择一个智能体以授予访问权限。", "makePodInviteOnly": "将此 Pod 设为仅限邀请", "podInviteOnlyNow": "此 Pod 现已设为仅限邀请。再次选择“授权”继续。", 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', )} /> = ({ 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..9c47c5c61c --- /dev/null +++ b/frontend/src/v2/connectors/ConnectorDrawer.tsx @@ -0,0 +1,41 @@ +// 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; + children: React.ReactNode; +} + +const ConnectorDrawer: React.FC = ({ type, title, onClose, 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}

+ +
+
{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..1c40f6fbd0 --- /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 + GAP, left }); + }, [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..30e0b03ed8 --- /dev/null +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -0,0 +1,483 @@ +// 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, 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 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)) + )); + 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); + + 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(); + const hasConnection = item.connector !== null || (item.kind === 'catalog' && 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.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) return null; + 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) && ( +
+ {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; + const availableCount = liveChannels.length + liveTools.length - 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 nothing = !c.loading && q && liveChannels.length + offChannels.length + toolEntries.length + orphanHidden.length === 0; + + const offToggle = (group: 'channels' | 'tools', count: number) => (count > 0 ? ( + + ) : null); + + return ( +
+
+

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

+ {!c.loading && {meta}} + + +
+ {(['all', 'channels', 'tools'] as Filter[]).map((value) => ( + + ))} +
+
+ + {c.loading &&
{t('connectors.loading', { defaultValue: 'Loading connectors…' })}
} + + {!c.loading && ( +
+
+ {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 })} +

+ )} + + {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))} +
+ {offOpen.tools && orphanHidden.length > 0 && ( +
{orphanHidden.map(tp.renderHiddenConnection)}
+ )} +
+ )} + + {nothing &&

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

} +
+ {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..1c520b77a9 --- /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 old +// V2ConnectorsPage (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..fa290a41f8 --- /dev/null +++ b/frontend/src/v2/connectors/connectors.css @@ -0,0 +1,122 @@ +/* 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 so light and dark switch together; the + key gradient has no v2 token yet, so it is named once here. */ +.v2-cx { + --cx-key-a: var(--v2-surface); + --cx-key-b: var(--v2-bg-subtle); + --cx-key-edge: var(--v2-border-soft); + --cx-pop-bg: var(--v2-surface); + --cx-pop-shadow: 0 0 0 1px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.04), 0 18px 36px -14px rgba(16, 24, 40, 0.28); + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; +} +:root[data-theme='dark'] .v2-cx { + --cx-key-a: #1e2531; + --cx-key-b: #191f29; + --cx-key-edge: rgba(255, 255, 255, 0.1); + --cx-pop-bg: #232b38; + --cx-pop-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 18px 36px -14px rgba(0, 0, 0, 0.75); +} + +/* 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-group__title svg { width: 13px; height: 13px; } +.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; right: 12px; bottom: calc(84px + env(safe-area-inset-bottom, 0px)); top: auto !important; width: auto; } +} + +/* 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..7124efc6a6 --- /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 old V2ConnectorTools +// (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..887df61e0a --- /dev/null +++ b/frontend/src/v2/connectors/useChannelConnectors.ts @@ -0,0 +1,1073 @@ +// Channel connectors: state, effects and verbs of the connectors page, moved out +// of the old V2ConnectorsPage so the tile page and its panels share one model +// (TASK-733). Behaviour is unchanged; the UI that drew it is gone. +import { useCallback, useEffect, 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; + 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); + + 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, + needsYouCount, + headMeta, + itemType, + kickerFor, + needsUser, + selectedItem, + selectItem, + runAction, + isInactive, + liveItems, + inactiveItems, + 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..1070bd2a86 --- /dev/null +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -0,0 +1,615 @@ +// Tool connectors: state, effects and verbs of the Tools list, moved out of the +// old V2ConnectorTools so the tile page and its panels share one model (TASK-733). +// Behaviour is unchanged; the rows that drew it are gone. +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, + entryIsGranted, +} 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 [catalog, setCatalog] = 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 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 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); + // 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); + 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, + rows, + notYet, + liveRows, + inactiveRows, + matchedHiddenConnections, + inactiveCount, + grantedCount, + moreCount, + selected, + openDraft, + openGithubAppSetup, + draftEntry, + draftTools, + selectedAgentIds, + submitDraft, + fixJoinPolicy, + connectHostedMcp, + submitGithubAppSetup, + markRevoked, + retryStaleRevoke, + revoke, + removeHiddenConnection, + disconnect, + }; +}; + +export type ToolConnectorsModel = ReturnType; From 290f25becc23507ad66107e8523c326e8cce5bd4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:22:53 -0700 Subject: [PATCH 6/8] refactor(connectors): retire the old Connectors page, retarget its tests (TASK-733) Deletes V2ConnectorsPage, V2ConnectorTools and V2InactiveFold and the v2.css rules nothing references any more. The behaviour tests move onto the new page (ConnectorsPage.channels / .tools, 173 tests) and a layout suite covers the tile surface (popover, drawer, filter, search, "+N off", add tile). Guard tests, the copy budget and the i18n manifest point at the new files; four presence tests pin the load-bearing rules in connectors.css. Restores the confirmed-disconnect filter on stale catalogue reads that the hook port had dropped, keeps a live grant listed when its catalogue entry is gone, closes the reveal when its act opens a drawer, and adds connectors.closeDetails to the locales. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- frontend/.eslintrc.js | 9 +- .../i18n/__tests__/translationKeys.test.ts | 2 +- .../__tests__/zhNumberUnitSpacing.test.ts | 11 +- frontend/src/i18n/locales/en.json | 1 + frontend/src/i18n/locales/zh-CN.json | 5 +- frontend/src/v2/__testkit__/axiosMock.ts | 17 + frontend/src/v2/__testkit__/connectorsKit.tsx | 141 ++ .../ConnectorsPage.channels.test.tsx | 1499 ++++++++++++++ .../__tests__/ConnectorsPage.layout.test.tsx | 286 +++ .../__tests__/ConnectorsPage.tools.test.tsx | 1301 ++++++++++++ .../__tests__/V2ConnectorTools.fold.test.tsx | 132 -- .../v2/__tests__/V2ConnectorTools.test.tsx | 1236 ------------ .../v2/__tests__/V2ConnectorsPage.test.tsx | 1734 ---------------- .../src/v2/__tests__/V2InactiveFold.test.tsx | 30 - .../src/v2/__tests__/v2-copy-budget.test.tsx | 8 +- .../v2/__tests__/v2-layout-invariants.test.ts | 186 +- .../src/v2/components/V2ConnectorTools.tsx | 1256 ------------ .../src/v2/components/V2ConnectorsPage.tsx | 1755 ----------------- frontend/src/v2/components/V2InactiveFold.tsx | 31 - frontend/src/v2/connectors/ConnectorsPage.tsx | 31 +- frontend/src/v2/connectors/channelModel.ts | 4 +- frontend/src/v2/connectors/toolModel.ts | 4 +- .../src/v2/connectors/useChannelConnectors.ts | 2 +- .../src/v2/connectors/useToolConnectors.ts | 12 +- frontend/src/v2/v2.css | 118 +- 25 files changed, 3382 insertions(+), 6429 deletions(-) create mode 100644 frontend/src/v2/__testkit__/axiosMock.ts create mode 100644 frontend/src/v2/__testkit__/connectorsKit.tsx create mode 100644 frontend/src/v2/__tests__/ConnectorsPage.channels.test.tsx create mode 100644 frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx create mode 100644 frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx delete mode 100644 frontend/src/v2/__tests__/V2ConnectorTools.fold.test.tsx delete mode 100644 frontend/src/v2/__tests__/V2ConnectorTools.test.tsx delete mode 100644 frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx delete mode 100644 frontend/src/v2/__tests__/V2InactiveFold.test.tsx delete mode 100644 frontend/src/v2/components/V2ConnectorTools.tsx delete mode 100644 frontend/src/v2/components/V2ConnectorsPage.tsx delete mode 100644 frontend/src/v2/components/V2InactiveFold.tsx 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 40386c3f6b..922e98e483 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2337,6 +2337,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.", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index efea0acc25..6a0354597e 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2265,8 +2265,8 @@ "filterLabel": "显示", "channelsGroup": "频道", "addChannelTile": "频道", - "meta": "{{on}} 个已开启 · {{available}} 个可用", - "offCount": "另有 {{n}} 个未开启", + "meta": "{{on}}个已开启 · {{available}}个可用", + "offCount": "另有{{n}}个未开启", "filter": { "all": "全部", "channels": "频道", @@ -2327,6 +2327,7 @@ "availableTelegram": "发一条消息", "cancel": "取消", "channelDetails": "频道详情", + "closeDetails": "关闭", "channelSees": "频道可见内容", "channels": "你的频道", "codeError": "无法生成新的启用码。", diff --git a/frontend/src/v2/__testkit__/axiosMock.ts b/frontend/src/v2/__testkit__/axiosMock.ts new file mode 100644 index 0000000000..87be18d352 --- /dev/null +++ b/frontend/src/v2/__testkit__/axiosMock.ts @@ -0,0 +1,17 @@ +// The axios stand-in the Connectors suites share. It imports nothing, so a suite's +// `jest.mock('axios', () => 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..ecaeb821d9 --- /dev/null +++ b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx @@ -0,0 +1,286 @@ +// @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(); + }); +}); 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..0c2cf1af30 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,73 @@ 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'); + }); + + 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 +2779,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 +3095,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 +4950,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/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx index 30e0b03ed8..7b86845da2 100644 --- a/frontend/src/v2/connectors/ConnectorsPage.tsx +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -31,6 +31,9 @@ interface Pop { 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 = () => (
{label}
{description &&

{description}

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

{row.line}

} {row.detail &&

{row.detail}

}
{row.action === 'connect' && ( @@ -337,12 +347,19 @@ const ConnectorsPage: React.FC = () => { } if (m.githubAppSetup) { return ( - + {tp.renderGithubAppSetup()} ); } - if (!toolEntry) return null; + 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)); @@ -350,6 +367,7 @@ const ConnectorsPage: React.FC = () => { {!toolGranted(toolEntry) && (
+

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

{tp.renderEntryNote(toolEntry)}
{tp.renderEntryActions(toolEntry)}
@@ -381,7 +399,7 @@ const ConnectorsPage: React.FC = () => { const errorText = c.hostedMcpCallbackError || c.error || c.slackCallbackError || ( !m.draft && !m.selected && !m.githubAppSetup ? m.error : null ); - const nothing = !c.loading && q && liveChannels.length + offChannels.length + toolEntries.length + orphanHidden.length === 0; + 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 ? ( @@ -464,6 +482,7 @@ const ConnectorsPage: React.FC = () => { {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)}
)} @@ -472,7 +491,7 @@ const ConnectorsPage: React.FC = () => { {nothing &&

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

} - {renderPopover()} + {!channelDrawerOpen && !toolDrawerOpen && renderPopover()} {renderChannelDrawer() || (toolDrawerOpen ? renderToolDrawer() : null)} )} diff --git a/frontend/src/v2/connectors/channelModel.ts b/frontend/src/v2/connectors/channelModel.ts index 1c520b77a9..66638aa54e 100644 --- a/frontend/src/v2/connectors/channelModel.ts +++ b/frontend/src/v2/connectors/channelModel.ts @@ -1,6 +1,6 @@ // Channel connectors: the shapes the integrations and catalog routes return, and -// the pure helpers the channel hook and panels share. Moved whole from the old -// V2ConnectorsPage (TASK-733); nothing here renders. +// 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'; diff --git a/frontend/src/v2/connectors/toolModel.ts b/frontend/src/v2/connectors/toolModel.ts index 7124efc6a6..cca9ddba0b 100644 --- a/frontend/src/v2/connectors/toolModel.ts +++ b/frontend/src/v2/connectors/toolModel.ts @@ -1,6 +1,6 @@ // Tool connectors: the shapes the installables and grants routes return, and the -// pure helpers the tool hook and panels share. Moved from the old V2ConnectorTools -// (TASK-733); nothing here renders. +// 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'; diff --git a/frontend/src/v2/connectors/useChannelConnectors.ts b/frontend/src/v2/connectors/useChannelConnectors.ts index 887df61e0a..911566a2df 100644 --- a/frontend/src/v2/connectors/useChannelConnectors.ts +++ b/frontend/src/v2/connectors/useChannelConnectors.ts @@ -1,5 +1,5 @@ // Channel connectors: state, effects and verbs of the connectors page, moved out -// of the old V2ConnectorsPage so the tile page and its panels share one model +// of the retired channels page so the tile page and its panels share one model // (TASK-733). Behaviour is unchanged; the UI that drew it is gone. import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index 1070bd2a86..84725fe53b 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -1,5 +1,5 @@ // Tool connectors: state, effects and verbs of the Tools list, moved out of the -// old V2ConnectorTools so the tile page and its panels share one model (TASK-733). +// retired tools list so the tile page and its panels share one model (TASK-733). // Behaviour is unchanged; the rows that drew it are gone. import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -39,7 +39,9 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa const viewerId = currentUser?._id ? String(currentUser._id) : ''; const isGranter = (grant: ToolGrant): boolean => Boolean(viewerId) && grant.grantedBy === viewerId; const [rawGrants, setGrants] = useState(null); - const [catalog, setCatalog] = useState([]); + 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>({}); @@ -63,6 +65,10 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa 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) } @@ -486,6 +492,7 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa 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)); @@ -504,6 +511,7 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa 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 }); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 29960f2c40..04350b11bd 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -10809,7 +10809,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, @@ -10830,7 +10829,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, @@ -10912,55 +10910,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; @@ -10983,7 +10935,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; @@ -11000,19 +10951,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); } @@ -11024,26 +10970,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). */ @@ -11052,7 +10981,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 { @@ -11093,9 +11022,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; @@ -11107,12 +11033,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; } @@ -11156,7 +11080,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 @@ -11183,18 +11106,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; } @@ -11209,7 +11120,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; } @@ -11218,24 +11130,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 @@ -11247,9 +11150,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. */ @@ -11259,8 +11159,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 From 353970a45c80521836dc7f5525719a702dcf6a86 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:41:59 -0700 Subject: [PATCH 7/8] fix(connectors): review fixes on the rebuild (TASK-733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - phone sheet: left/max-height win over the popover's inline style, above the tab bar - colours from the Signal tokens (--v2-key-*, --v2-shadow-pop), so dark and auto themes apply; the old data-theme selector never matched - a reveal that opened a drawer stays closed when the drawer closes - popover position adds the body's scroll offset - page errors and the removed-connection notice also render inside the drawer (the phone sheet covers the page body) - a hosted-MCP manual-revoke notice opens its tile once, so it is not missed - header count is taken over everything, so it holds while searching - an installed provider this instance no longer offers opens the reveal (reason, Ask), not an empty drawer - drop dead hook members and the locale keys only they used; zh-CN wording (已开启{{on}}个, 未启用) - tests: reveal stays closed, steady count, unoffered installed provider, phone-sheet and token presence Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- frontend/src/i18n/locales/en.json | 12 ---- frontend/src/i18n/locales/zh-CN.json | 16 +---- .../__tests__/ConnectorsPage.layout.test.tsx | 34 ++++++++++ .../v2/__tests__/v2-layout-invariants.test.ts | 9 +++ .../src/v2/connectors/ConnectorDrawer.tsx | 6 +- .../src/v2/connectors/ConnectorPopover.tsx | 2 +- frontend/src/v2/connectors/ConnectorsPage.tsx | 67 ++++++++++++++----- frontend/src/v2/connectors/connectors.css | 24 +++---- .../src/v2/connectors/useChannelConnectors.ts | 50 +------------- .../src/v2/connectors/useToolConnectors.ts | 33 +-------- 10 files changed, 110 insertions(+), 143 deletions(-) diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index c705fb4e8b..e9362b64f7 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2283,13 +2283,6 @@ "channels": "Channels", "tools": "Tools" }, - "about": "About connectors", - "counts": { - "connected_one": "{{count}} connected", - "connected_other": "{{count}} connected", - "needsYou_one": "{{count}} needs you", - "needsYou_other": "{{count}} need you" - }, "inactiveFold": "inactive · {{n}}", "loading": "Loading connectors…", "loadError": "Could not load connectors.", @@ -2347,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}}", @@ -2543,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}}", @@ -2552,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", @@ -2599,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 101c821c50..8d80f7210c 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2266,20 +2266,13 @@ "filterLabel": "显示", "channelsGroup": "频道", "addChannelTile": "频道", - "meta": "{{on}}个已开启 · {{available}}个可用", - "offCount": "另有{{n}}个未开启", + "meta": "已开启{{on}}个 · 可用{{available}}个", + "offCount": "另有{{n}}个未启用", "filter": { "all": "全部", "channels": "频道", "tools": "工具" }, - "about": "关于连接器", - "counts": { - "connected_one": "已连接{{count}}个频道", - "connected_other": "已连接{{count}}个频道", - "needsYou_one": "{{count}}项需要你处理", - "needsYou_other": "{{count}}项需要你处理" - }, "inactiveFold": "非活跃 · {{n}}", "loading": "正在加载连接器…", "loadError": "无法加载连接器。", @@ -2337,7 +2330,6 @@ "connectChannel": "连接频道", "copied": "已复制", "copyCommand": "复制命令", - "description": "将频道连接到 Pod。消息会以你的身份进入 Pod;你可以选择哪些内容回传到频道。", "errorLine": "连接已断开。", "errorReconnect": "重新连接以恢复", "gateLead": "{{pod}} 的主导智能体", @@ -2546,7 +2538,6 @@ "grantDetails": "授权详情", "grantError": "无法授权。", "grantedByOn": "{{rel}}由 {{member}} 授权。", - "grantedCount": "已授权{{count}}个", "grantedOn": "{{rel}}授权。", "grantedToPod": "授权给 {{pod}}", "grantedToPodBy": "由 {{member}} 授权给 {{pod}}", @@ -2555,14 +2546,12 @@ "installationId": "安装 ID", "installingGitHubApp": "安装中…", "keep": "保留", - "loading": "正在加载工具…", "manage": "管理", "mayUse": "{{agents}} 可以使用", "mode": "可执行的操作", "modeRead": "读取", "modeWrite": "读取和写入", "modeWriteConfirm": "读取和写入,写入前需确认", - "moreCount": "另有{{count}}个", "existingConnection": "现有连接", "noLongerOffered": "此连接器已不在本实例提供。", "removeConnection": "移除连接", @@ -2602,7 +2591,6 @@ "revokeOld": "撤销旧授权", "revokedByLine": "{{rel}}由 {{member}} 撤销", "revokedLine": "{{rel}}撤销", - "search": "搜索工具", "segAll": "全部", "segGranted": "已授权", "segNotYet": "尚未授权", diff --git a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx index ecaeb821d9..7388cd65a5 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx @@ -282,5 +282,39 @@ describe('Connectors page: the tile surface', () => { 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('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__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 0c2cf1af30..0362bdb316 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2483,6 +2483,15 @@ describe('v2 layout invariants (CSS rule presence)', () => { 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', () => { diff --git a/frontend/src/v2/connectors/ConnectorDrawer.tsx b/frontend/src/v2/connectors/ConnectorDrawer.tsx index 9c47c5c61c..0b2f66ce33 100644 --- a/frontend/src/v2/connectors/ConnectorDrawer.tsx +++ b/frontend/src/v2/connectors/ConnectorDrawer.tsx @@ -9,10 +9,12 @@ 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, children }) => { +const ConnectorDrawer: React.FC = ({ type, title, onClose, notices, children }) => { const { t } = useTranslation(); const ref = useRef(null); @@ -33,7 +35,7 @@ const ConnectorDrawer: React.FC = ({ type, title, onClose, children }) => -
{children}
+
{notices}{children}
); }; diff --git a/frontend/src/v2/connectors/ConnectorPopover.tsx b/frontend/src/v2/connectors/ConnectorPopover.tsx index 1c40f6fbd0..627354efef 100644 --- a/frontend/src/v2/connectors/ConnectorPopover.tsx +++ b/frontend/src/v2/connectors/ConnectorPopover.tsx @@ -25,7 +25,7 @@ const ConnectorPopover: React.FC = ({ anchor, container, label, onClose, 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 + GAP, left }); + setPlace({ top: a.bottom - c.top + container.scrollTop + GAP, left: left + container.scrollLeft }); }, [anchor, container]); useEffect(() => { diff --git a/frontend/src/v2/connectors/ConnectorsPage.tsx b/frontend/src/v2/connectors/ConnectorsPage.tsx index 7b86845da2..9ceb4b4505 100644 --- a/frontend/src/v2/connectors/ConnectorsPage.tsx +++ b/frontend/src/v2/connectors/ConnectorsPage.tsx @@ -3,7 +3,7 @@ // 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, useMemo, useRef, useState } from 'react'; +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'; @@ -111,6 +111,30 @@ const ConnectorsPage: React.FC = () => { 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); @@ -129,7 +153,8 @@ const ConnectorsPage: React.FC = () => { const openChannel = (item: ListItem, event: React.MouseEvent) => { closeTools(); - const hasConnection = item.connector !== null || (item.kind === 'catalog' && Boolean(item.entry.installation)); + // 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); @@ -321,7 +346,7 @@ const ConnectorsPage: React.FC = () => { if (c.adding) { const title = t('connectors.connectChannel', { defaultValue: 'Connect a channel' }); return ( - +

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

{renderAddForm()} @@ -331,7 +356,7 @@ const ConnectorsPage: React.FC = () => { } if (!drawerItem) return null; return ( - + ); @@ -340,14 +365,14 @@ const ConnectorsPage: React.FC = () => { const renderToolDrawer = () => { if (m.draft) { return ( - + {tp.renderDraft()} ); } if (m.githubAppSetup) { return ( - + {tp.renderGithubAppSetup()} ); @@ -355,7 +380,7 @@ const ConnectorsPage: React.FC = () => { if (!toolEntry) { if (!m.selected) return null; return ( - + {tp.renderAside(m.selected)} ); @@ -364,7 +389,7 @@ const ConnectorsPage: React.FC = () => { 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)}

@@ -391,7 +416,10 @@ const ConnectorsPage: React.FC = () => { const connectedChannels = c.connectedCount; const grantedTools = m.catalog.filter(toolGranted).length; const onCount = connectedChannels + grantedTools; - const availableCount = liveChannels.length + liveTools.length - onCount; + // 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'; @@ -399,6 +427,18 @@ const ConnectorsPage: React.FC = () => { 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 ? ( @@ -442,14 +482,7 @@ const ConnectorsPage: React.FC = () => { {!c.loading && (
- {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 })} -

- )} + {!channelDrawerOpen && !toolDrawerOpen && noticeBlock} {showChannels && (
diff --git a/frontend/src/v2/connectors/connectors.css b/frontend/src/v2/connectors/connectors.css index fa290a41f8..b535f7277e 100644 --- a/frontend/src/v2/connectors/connectors.css +++ b/frontend/src/v2/connectors/connectors.css @@ -1,25 +1,18 @@ /* 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 so light and dark switch together; the - key gradient has no v2 token yet, so it is named once here. */ + 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-surface); - --cx-key-b: var(--v2-bg-subtle); - --cx-key-edge: var(--v2-border-soft); - --cx-pop-bg: var(--v2-surface); - --cx-pop-shadow: 0 0 0 1px rgba(16, 24, 40, 0.08), 0 2px 4px rgba(16, 24, 40, 0.04), 0 18px 36px -14px rgba(16, 24, 40, 0.28); + --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%; } -:root[data-theme='dark'] .v2-cx { - --cx-key-a: #1e2531; - --cx-key-b: #191f29; - --cx-key-edge: rgba(255, 255, 255, 0.1); - --cx-pop-bg: #232b38; - --cx-pop-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1), 0 18px 36px -14px rgba(0, 0, 0, 0.75); -} /* 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; } @@ -45,7 +38,6 @@ .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-group__title svg { width: 13px; height: 13px; } .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); } @@ -106,7 +98,7 @@ } /* 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; right: 12px; bottom: calc(84px + env(safe-area-inset-bottom, 0px)); top: auto !important; width: auto; } + .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 */ diff --git a/frontend/src/v2/connectors/useChannelConnectors.ts b/frontend/src/v2/connectors/useChannelConnectors.ts index 911566a2df..e9d252475e 100644 --- a/frontend/src/v2/connectors/useChannelConnectors.ts +++ b/frontend/src/v2/connectors/useChannelConnectors.ts @@ -1,6 +1,6 @@ // 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). Behaviour is unchanged; the UI that drew it is gone. +// (TASK-733). The rows' behaviour is unchanged; the page that drew them is gone. import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; @@ -896,41 +896,9 @@ export const useChannelConnectors = () => { // 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); @@ -967,15 +935,6 @@ export const useChannelConnectors = () => { 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); return { @@ -1055,17 +1014,10 @@ export const useChannelConnectors = () => { itemLabel, items, connectedCount, - needsYouCount, - headMeta, itemType, - kickerFor, needsUser, - selectedItem, selectItem, runAction, - isInactive, - liveItems, - inactiveItems, busyFor, }; }; diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index 84725fe53b..e292018a77 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -1,6 +1,6 @@ // 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). -// Behaviour is unchanged; the rows that drew it are gone. +// 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'; @@ -15,7 +15,6 @@ import { WRITE_MODES, isDead, entryCovers, - entryIsGranted, } from './toolModel'; import type { GrantWriteMode, @@ -226,28 +225,6 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa }; 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) => { @@ -594,14 +571,6 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa entryModeCopy, usedRecently, q, - rows, - notYet, - liveRows, - inactiveRows, - matchedHiddenConnections, - inactiveCount, - grantedCount, - moreCount, selected, openDraft, openGithubAppSetup, From ce310039b780a4f54d0311b909c318b7f5d8bd71 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:44:42 -0700 Subject: [PATCH 8/8] fix(connectors): memoise the pod list so a failed /api/pods does not refetch in a loop (TASK-733) CodeRabbit: while pods is null, podList was a new [] each render, so useToolConnectors' load re-fired every render. Test: 189 grant fetches in 300ms before, 2 or fewer now. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Keel Agent-Model: claude/claude-opus-5-5/high --- .../src/v2/__tests__/ConnectorsPage.layout.test.tsx | 10 ++++++++++ frontend/src/v2/connectors/useChannelConnectors.ts | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx index 7388cd65a5..75be094fb6 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.layout.test.tsx @@ -289,6 +289,16 @@ describe('Connectors page: the tile surface', () => { 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(); diff --git a/frontend/src/v2/connectors/useChannelConnectors.ts b/frontend/src/v2/connectors/useChannelConnectors.ts index e9d252475e..4139b93f4e 100644 --- a/frontend/src/v2/connectors/useChannelConnectors.ts +++ b/frontend/src/v2/connectors/useChannelConnectors.ts @@ -1,7 +1,7 @@ // 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, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useV2Api } from '../hooks/useV2Api'; import { useRelativeNow } from '../hooks/useRelativeNow'; @@ -74,7 +74,8 @@ export const useChannelConnectors = () => { // over if it is still saying it an hour later (TASK-131). const now = useRelativeNow(); const adding = addingType !== null; - const podList = pods || []; + // 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).