diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f6617a62a..5db86b548 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2728,7 +2728,11 @@ "trailError": "Could not read the trail.", "viewGrant": "View {{tool}} in {{pod}}", "listSeparator": ", ", - "sentenceSeparator": " " + "sentenceSeparator": " ", + "connectionSeparator": " · ", + "connectionSignedIn": "Signed in", + "connectionKey": "Key", + "disconnectConnectionLabel": "Disconnect {{label}}: {{connection}}" }, "agentManage": { "runtime": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index da78fef33..c238dfeda 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -2731,7 +2731,11 @@ "trailError": "无法读取调用记录。", "viewGrant": "在 {{pod}} 中查看 {{tool}}", "listSeparator": "、", - "sentenceSeparator": "" + "sentenceSeparator": "", + "connectionSeparator": " · ", + "connectionSignedIn": "已登录", + "connectionKey": "密钥", + "disconnectConnectionLabel": "断开 {{label}}:{{connection}}" }, "agentManage": { "runtime": { diff --git a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx index d5fb519d7..f92f3a487 100644 --- a/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx +++ b/frontend/src/v2/__tests__/ConnectorsPage.tools.test.tsx @@ -679,7 +679,7 @@ describe('Connectors page: tools', () => { 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 () => { + it('two connections the server does not describe offer no Disconnect, since neither can be named', async () => { const two = { ...atlassian, connections: [...atlassian.connections, { connectionId: 'conn-atl-2', owner: 'me', repo: 'atlassian' }] }; mockTools({ entries: [two], grants: [] }); renderPage(); @@ -689,6 +689,91 @@ describe('Connectors page: tools', () => { expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument(); }); + // TASK-330: one sign-in row plus operator-key rows bound per agent. The + // server describes each row, so each gets its own named Disconnect. + // Labels carry the viewer's local date, so expected text is computed the + // same way and holds in any time zone the suite runs in. + const day = (at) => new Date(at).toLocaleDateString('en', { month: 'short', day: 'numeric' }); + const SIGNED_IN = `Signed in · ${day('2026-10-30T12:00:00Z')}`; + const KEY_SCOUT = `Key · Scout · ${day('2026-10-31T12:00:00Z')}`; + const twoNamed = { + ...atlassian, + connections: [ + { connectionId: 'conn-atl', owner: '', repo: '', connectedAt: '2026-10-30T12:00:00Z' }, + { connectionId: 'conn-atl-key', owner: '', repo: '', agentId: scout.userId, connectedAt: '2026-10-31T12:00:00Z' }, + ], + }; + + it('two described connections each offer a named Disconnect that drops only that connection', async () => { + mockTools({ entries: [twoNamed], grants: [] }); + axios.delete.mockResolvedValue({ data: { message: 'ok' } }); + renderPage(); + + const popover = await openToolPopover('Atlassian'); + expect(within(popover).getByText(SIGNED_IN)).toBeInTheDocument(); + expect(within(popover).getByText(KEY_SCOUT)).toBeInTheDocument(); + fireEvent.click(within(popover).getByRole('button', { name: `Disconnect Atlassian: ${KEY_SCOUT}` })); + expect(within(popover).getAllByRole('button', { name: 'Yes, disconnect' })).toHaveLength(1); + expect(within(popover).getByRole('button', { name: `Disconnect Atlassian: ${SIGNED_IN}` })).toBeInTheDocument(); + fireEvent.click(within(popover).getByRole('button', { name: 'Yes, disconnect' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl-key', expect.any(Object))); + expect(axios.delete).toHaveBeenCalledTimes(1); + }); + + it('dropping one of two connections leaves the grant to the re-read, not marked revoked in place', async () => { + mockTools({ entries: [twoNamed], grants: [grantAtl] }); + axios.delete.mockResolvedValue({ data: { message: 'ok' } }); + renderPage(); + + const { aside } = await openRow('Atlassian', 'View Atlassian in Launch pod'); + fireEvent.click(within(aside).getByRole('button', { name: `Disconnect Atlassian: ${SIGNED_IN}` })); + fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); + await waitFor(() => expect(axios.delete).toHaveBeenCalledWith('/api/integrations/conn-atl', expect.any(Object))); + const card = await screen.findByRole('complementary', { name: 'Grant details' }); + // A grant does not name its connection, so only the server can say it ended. + expect(within(card).queryByText(/Revoked by/)).not.toBeInTheDocument(); + expect(within(card).getByRole('button', { name: 'Revoke' })).toBeInTheDocument(); + }); + + it('a key connection whose agent is not on any roster is named by key and date', async () => { + const unknownAgent = { ...twoNamed, connections: [twoNamed.connections[0], { ...twoNamed.connections[1], agentId: 'someone-else' }] }; + mockTools({ entries: [unknownAgent], grants: [] }); + renderPage(); + + const popover = await openToolPopover('Atlassian'); + expect(within(popover).getByText(`Key · ${day('2026-10-31T12:00:00Z')}`)).toBeInTheDocument(); + }); + + it('two connections that read the same offer no Disconnect, since neither can be told apart', async () => { + const lookAlike = { + ...twoNamed, + connections: [ + { connectionId: 'conn-atl-k1', owner: '', repo: '', agentId: 'off-roster-1', connectedAt: '2026-10-31T12:00:00Z' }, + { connectionId: 'conn-atl-k2', owner: '', repo: '', agentId: 'off-roster-2', connectedAt: '2026-10-31T12:00:00Z' }, + ], + }; + mockTools({ entries: [lookAlike], grants: [] }); + renderPage(); + + const popover = await openToolPopover('Atlassian'); + expect(within(popover).getByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Disconnect/ })).not.toBeInTheDocument(); + }); + + it('a 502 partial removal of one of two connections leaves the grant to the re-read', async () => { + mockTools({ entries: [twoNamed], 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: ${SIGNED_IN}` })); + fireEvent.click(within(aside).getByRole('button', { name: 'Yes, disconnect' })); + expect(await screen.findByRole('link', { name: 'Open provider settings' })).toBeInTheDocument(); + const card = await screen.findByRole('complementary', { name: 'Grant details' }); + expect(within(card).queryByText(/Revoked by/)).not.toBeInTheDocument(); + expect(within(card).getByRole('button', { name: 'Revoke' })).toBeInTheDocument(); + }); + it('GitHub (the instance App connection) never offers Disconnect', async () => { renderPage(); await openTool('GitHub'); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 084c4edce..8b8e4180e 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -2565,6 +2565,11 @@ describe('v2 layout invariants (CSS rule presence)', () => { expect(asideIcon).toContain('height: 32px'); expect(asideIcon).toContain('min-height: 32px'); expect(v2).toContain('.v2-root button.v2-connector-aside__secondary.v2-connector-aside__icon { width: 44px; height: 44px; min-height: 44px; }'); + // A hosted connection's name sits in the aside's flex row beside its own + // Disconnect. The note class's `margin-top: 12px !important` dropped it + // about 6px below the button's centre, so this label carries no margin. + expect(ruleBody(v2, '.v2-connector-aside__conn')).toContain('font-size: 12px'); + expect(ruleBody(v2, '.v2-connector-aside__conn')).not.toContain('margin'); expect(ruleBody(v2, '.v2-connector-row__details')).toContain('padding-right: 8px'); expect(ruleBody(v2, '.v2-page-head__meta')).toContain('var(--v2-font-mono)'); expect(ruleBody(v2, '.v2-connector-row__glyph')).toContain('width: 20px'); diff --git a/frontend/src/v2/connectors/ToolPanels.tsx b/frontend/src/v2/connectors/ToolPanels.tsx index b3bbb6aec..457cbbb74 100644 --- a/frontend/src/v2/connectors/ToolPanels.tsx +++ b/frontend/src/v2/connectors/ToolPanels.tsx @@ -19,6 +19,7 @@ import type { ToolOutcome, ToolCatalogEntry, HiddenToolConnection, + ToolConnection, } from './toolModel'; import type { ToolConnectorsModel } from './useToolConnectors'; @@ -128,12 +129,55 @@ export const useToolPanels = (m: ToolConnectorsModel) => {
); }; + const agentNameById = (agentId?: string): string => { + if (!agentId) return ''; + const seat = Object.values(seats).flat().find((candidate) => candidate.userId === agentId); + return seat?.displayName || seat?.name || ''; + }; + // Names one hosted connection: a key row by its agent, the sign-in as such, + // and both by when. Null when the server did not describe the row (no + // connectedAt), so it cannot be told apart. + const connectionLabel = (connection: ToolConnection): string | null => { + if (!connection.connectedAt || Number.isNaN(Date.parse(connection.connectedAt))) return null; + const date = new Date(connection.connectedAt).toLocaleDateString(i18n.language, { month: 'short', day: 'numeric' }); + const separator = t('tools.connectionSeparator', { defaultValue: ' · ' }); + if (connection.agentId) { + return [t('tools.connectionKey', { defaultValue: 'Key' }), agentNameById(connection.agentId), date].filter(Boolean).join(separator); + } + return [t('tools.connectionSignedIn', { defaultValue: 'Signed in' }), date].join(separator); + }; // 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 hosted = entry && entry.connectionType === 'hosted-mcp' ? entry.connections : []; + if (!entry || hosted.length === 0) return null; + if (hosted.length === 1) { + const button = renderDisconnectOne(entry, hosted[0], variant, null); + return variant === 'aside' ?{error}
}