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' ?
{button}
: button; + } + // Several connections: each is named, so each gets its own Disconnect. A + // connection that cannot be told apart (the server did not describe it, or + // it reads the same as another) gets none rather than one that might drop + // the wrong connection. + const labelled = hosted.map((connection) => ({ connection, label: connectionLabel(connection) })); + const labels = labelled.map((row) => row.label); + if (labels.some((label) => !label) || new Set(labels).size < labels.length) return null; + return ( + <> + {labelled.map(({ connection, label }) => (variant === 'aside' ? ( +
+ {label} + {renderDisconnectOne(entry, connection, variant, label)} +
+ ) : ( + + {label} + {renderDisconnectOne(entry, connection, variant, label)} + + )))} + + ); + }; + const renderDisconnectOne = (entry: ToolCatalogEntry, connection: ToolConnection, variant: 'row' | 'aside', label: string | null) => { const cls = variant === 'row' ? 'v2-connector-row__action v2-connector-row__action--secondary' : 'v2-connector-aside__secondary'; @@ -150,7 +194,14 @@ export const useToolPanels = (m: ToolConnectorsModel) => { ); } return ( - ); @@ -583,9 +634,7 @@ export const useToolPanels = (m: ToolConnectorsModel) => { ))} - {isGranter(grant) && ( -
{renderDisconnect(entry, 'aside')}
- )} + {isGranter(grant) && renderDisconnect(entry, 'aside')} {error &&

{error}

}
diff --git a/frontend/src/v2/connectors/toolModel.ts b/frontend/src/v2/connectors/toolModel.ts index 65c4969ca..b7968c79f 100644 --- a/frontend/src/v2/connectors/toolModel.ts +++ b/frontend/src/v2/connectors/toolModel.ts @@ -76,7 +76,18 @@ export interface ToolCatalogEntry { unavailableReason?: string; broker?: { id: string }; tools: Array<{ name: string; description?: string; requiredWriteMode: GrantWriteMode; irreversible: boolean }>; - connections: Array<{ connectionId: string; owner: string; repo: string }>; + connections: ToolConnection[]; +} + +/** A hosted row also says when it was made and, for an operator key, its agent, + * so two of them can be told apart. */ +export interface ToolConnection { + connectionId: string; + owner: string; + repo: string; + connectedAt?: string; + /** The bot User a key row is bound to; absent on the OAuth sign-in. */ + agentId?: string; } export interface HiddenToolConnection { diff --git a/frontend/src/v2/connectors/useToolConnectors.ts b/frontend/src/v2/connectors/useToolConnectors.ts index 55d7cfa1b..e51164820 100644 --- a/frontend/src/v2/connectors/useToolConnectors.ts +++ b/frontend/src/v2/connectors/useToolConnectors.ts @@ -505,8 +505,9 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa 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)); + // now rather than waiting on a re-read that may fail. A grant does not say + // which connection it was minted on, so with several only the re-read can. + const ended = entry.connections.length > 1 ? [] : (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, @@ -523,7 +524,7 @@ export const useToolConnectors = (pods: V2Pod[], manualRevokeNotice: HostedMcpMa 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)); + const ended = entry.connections.length > 1 ? [] : (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]); diff --git a/frontend/src/v2/v2.css b/frontend/src/v2/v2.css index 19058ff56..c47ebe973 100644 --- a/frontend/src/v2/v2.css +++ b/frontend/src/v2/v2.css @@ -4238,6 +4238,8 @@ body.modern-ui.v2-canvas { .v2-connector-aside__refusal a { color: var(--v2-accent-text); text-decoration: underline; } .v2-connector-aside__note { margin-top: 12px !important; color: var(--v2-text-tertiary) !important; font-size: 12px !important; } +/* A hosted connection's name beside its own Disconnect: the note's type, centred on the button. */ +.v2-connector-aside__conn { color: var(--v2-text-tertiary); font-size: 12px; } /* Row C: the refusal the row's own button earned, beside that button in the aside (and on the row itself) — same bordered grammar as the page slot. */ .v2-connector-aside__refusal { margin-top: 10px !important; 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; }