diff --git a/frontend/src/agents/NewAgent.test.tsx b/frontend/src/agents/NewAgent.test.tsx index d6ffa3b8..f1dab53b 100644 --- a/frontend/src/agents/NewAgent.test.tsx +++ b/frontend/src/agents/NewAgent.test.tsx @@ -33,6 +33,10 @@ const mocks = vi.hoisted(() => { }); const { jsonResponse } = mocks; +vi.mock('../connectors/SignInAgainNotice', () => ({ + default: () => null, + useSignInAgain: () => ({ reconnect: vi.fn(), modals: null }), +})); vi.mock('react-redux', () => ({ useSelector: (selector: (state: unknown) => unknown) => selector(mockState), useDispatch: () => mocks.dispatch, diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index a25eee90..6f567f3f 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -54,6 +54,10 @@ import SourcesPopoverFooter from '../components/SourcesPopoverFooter'; import ToolIcon from '../components/ToolIcon'; import connectorsService from '../api/services/connectorsService'; import ConnectorIcon from '../connectors/ConnectorIcon'; +import { connectionNeedsSignIn } from '../connectors/connectorsSlice'; +import SignInAgainNotice, { + useSignInAgain, +} from '../connectors/SignInAgainNotice'; import type { Connection } from '../connectors/types'; import AgentDetailsModal from '../modals/AgentDetailsModal'; import ShareToTeamModal from '../teams/ShareToTeamModal'; @@ -165,6 +169,14 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { const [imageFile, setImageFile] = useState(null); const [userTools, setUserTools] = useState([]); const [rawUserTools, setRawUserTools] = useState([]); + // Connections behind the picker's tools that need signing in again. + const [brokenToolConnections, setBrokenToolConnections] = useState< + { connection: Connection; mcpToolId?: string }[] + >([]); + const [toolsReloadKey, setToolsReloadKey] = useState(0); + const signInAgain = useSignInAgain({ + onConnected: () => setToolsReloadKey((key) => key + 1), + }); const [availableModels, setAvailableModels] = useState([]); const [isSourcePopupOpen, setIsSourcePopupOpen] = useState(false); const [isToolsPopupOpen, setIsToolsPopupOpen] = useState(false); @@ -654,6 +666,13 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { ), group: groupFor(tool), }; + if (connectionNeedsSignIn(connection)) { + base.descriptionNode = ( +

+ {t('settings.connectors.health.signInAgain')} +

+ ); + } if (tool.name === 'remote_device') { const deviceId = (tool.config?.device_id as string) || ''; const meta = devicesById.get(deviceId); @@ -673,6 +692,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }); setUserTools(tools); setRawUserTools(visibleTools); + setBrokenToolConnections( + Array.from(connectionsById.values()) + .filter(connectionNeedsSignIn) + .flatMap((connection) => { + const own = visibleTools.filter( + (tool) => tool.connection_id === connection.id, + ); + if (own.length === 0) return []; + return [ + { + connection, + mcpToolId: own.find((tool) => tool.name === 'mcp_tool')?.id, + }, + ]; + }), + ); }; const getModels = async () => { const response = await modelService.getModels(token); @@ -697,7 +732,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }; getTools(); getModels(); - }, [token, mode]); + }, [token, mode, toolsReloadKey]); // Validate folder_id from URL against user's folders useEffect(() => { @@ -960,6 +995,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { /> } > + {signInAgain.modals} {agent.agent_type === 'workflow' && } 0 ? ( + connection, + )} + onReconnect={(connection) => { + setIsToolsPopupOpen(false); + signInAgain.reconnect( + connection, + brokenToolConnections.find( + (entry) => entry.connection.id === connection.id, + )?.mcpToolId, + ); + }} + /> + ) : undefined + } trigger={ - +
+ {notice} +
+ + +
} trigger={ diff --git a/frontend/src/connectors/ConnectionDrawer.test.tsx b/frontend/src/connectors/ConnectionDrawer.test.tsx index f926a3fc..e2ecf524 100644 --- a/frontend/src/connectors/ConnectionDrawer.test.tsx +++ b/frontend/src/connectors/ConnectionDrawer.test.tsx @@ -186,6 +186,27 @@ describe('ConnectionDrawer', () => { ).toBe('success'); }); + it('says in plain words that an expired sign-in needs redoing', async () => { + connectors.getConnection.mockResolvedValue({ + success: true, + connection: { + ...DETAIL, + status: 'reconnect_needed', + last_error: 'invalid_grant: token expired', + }, + }); + await render(); + const text = document.body.textContent ?? ''; + expect(text).toContain('settings.connectors.detail.expired'); + expect(text).toContain('settings.connectors.detail.account'); + expect(text).not.toContain('settings.connectors.detail.connectedAs'); + // The provider's message stays available on hover, not as the sentence. + expect(text).not.toContain('invalid_grant'); + expect( + document.body.querySelector('[title="invalid_grant: token expired"]'), + ).not.toBeNull(); + }); + it('labels synced content as Knowledge from this connection', async () => { await render(); expect(document.body.textContent).toContain( diff --git a/frontend/src/connectors/ConnectionDrawer.tsx b/frontend/src/connectors/ConnectionDrawer.tsx index 5fdbf882..62df6b91 100644 --- a/frontend/src/connectors/ConnectionDrawer.tsx +++ b/frontend/src/connectors/ConnectionDrawer.tsx @@ -63,7 +63,10 @@ const STATUS_VARIANT: Record< pending: 'neutral', }; -/** "Connected as …", or the key hint for pasted credentials. */ +/** + * "Connected as …", or the key hint for pasted credentials. A connection that + * is not working names its account without claiming it is connected. + */ function useAccountTitle() { const { t } = useTranslation(); return (detail: ConnectionDetail) => @@ -72,10 +75,26 @@ function useAccountTitle() { hint: detail.account_label, interpolation: { escapeValue: false }, }) - : t('settings.connectors.detail.connectedAs', { - account: detail.account_label, - interpolation: { escapeValue: false }, - }); + : t( + detail.status === 'connected' + ? 'settings.connectors.detail.connectedAs' + : 'settings.connectors.detail.account', + { + account: detail.account_label, + interpolation: { escapeValue: false }, + }, + ); +} + +/** What is wrong, in plain words rather than the provider's message. */ +function useProblem() { + const { t } = useTranslation(); + return (detail: ConnectionDetail): string | null => + detail.status === 'reconnect_needed' + ? t('settings.connectors.detail.expired') + : detail.status === 'error' + ? t('settings.connectors.detail.broken') + : detail.last_error; } function RemoveConnectionModal({ @@ -242,6 +261,7 @@ function AccountSection({ }) { const { t } = useTranslation(); const accountTitle = useAccountTitle(); + const problem = useProblem(); const [refreshing, setRefreshing] = useState(false); const canSync = connector.setup.sync !== 'off' && !!connector.sync_ingestor; const isMcp = detail.tools.some((tool) => tool.name === 'mcp_tool'); @@ -261,43 +281,71 @@ function AccountSection({ variant: 'destructive', }); + // An expired or failing sign-in gets its own box under the account, with + // room to say what happened at any width. + const broken = + detail.status === 'reconnect_needed' || detail.status === 'error'; + return (
- - - {detail.last_error} - ) : undefined - } - trailing={ -
- - {t(`settings.connectors.connectionStatus.${detail.status}`)} - - {detail.status !== 'connected' && ( - - )} - -
- } - /> -
-
+
+ + + {detail.last_error} + ) : undefined + } + trailing={ +
+ + {t(`settings.connectors.connectionStatus.${detail.status}`)} + + {!broken && detail.status !== 'connected' && ( + + )} + +
+ } + /> +
+
+ {broken && ( + + + {/* The provider's own message stays on hover, for debugging. */} + + {problem(detail)} + +
+ +
+
+ )} +
{canSync && (
({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.name ? `${key}:${opts.name}` : key, + }), +})); + +const launch = vi.hoisted(() => vi.fn()); +vi.mock('./useConnectorLauncher', () => ({ + default: () => ({ launch, modals: null }), +})); + +import connectorsReducer from './connectorsSlice'; +import SignInAgainNotice, { useSignInAgain } from './SignInAgainNotice'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const LINEAR = { + key: 'mcp:linear', + name: 'Linear', + auth_kind: 'mcp_oauth', + mcp_url: 'https://mcp.linear.app/mcp', + available: true, +}; +const CUSTOM = { key: 'custom_mcp', name: 'MCP server', auth_kind: 'mcp' }; + +function Where() { + return ( +
+ {useLocation().pathname + useLocation().search} +
+ ); +} + +function Harness({ + connectorKey, + mcpToolId, +}: { + connectorKey: string; + mcpToolId?: string; +}) { + const { reconnect, modals } = useSignInAgain(); + return ( + <> + reconnect(connection, mcpToolId)} + /> + {modals} + + ); +} + +describe('SignInAgainNotice', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + launch.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (connectorKey: string, mcpToolId?: string) => { + const store = configureStore({ + reducer: { connectors: connectorsReducer }, + preloadedState: { + connectors: { + enabled: true, + loading: false, + loaded: true, + failed: false, + catalog: [LINEAR, CUSTOM], + connections: [], + }, + }, + } as Parameters[0]); + await act(async () => { + root.render( + + + + + } + /> + } /> + + + , + ); + }); + }; + + const reconnectButton = () => + Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'settings.connectors.status.reconnect', + )!; + + it('names the connection that needs signing in again', async () => { + await render('mcp:linear'); + expect(container.textContent).toContain( + 'settings.connectors.health.pickerNotice:Linear', + ); + }); + + it('reconnects a service in place, keeping its MCP tool', async () => { + await render('mcp:linear', 'tool-lin'); + await act(async () => reconnectButton().click()); + expect(launch).toHaveBeenCalledWith(LINEAR, { + mode: 'reconnect', + connectionId: 'conn-2', + mcpServer: { id: 'tool-lin' }, + }); + }); + + it('opens the connector page when the sign-in needs its form', async () => { + await render('custom_mcp'); + await act(async () => reconnectButton().click()); + expect(launch).not.toHaveBeenCalled(); + expect(container.querySelector('[data-testid="where"]')?.textContent).toBe( + '/settings/connectors?connector=custom_mcp', + ); + }); + + it('renders nothing when every connection works', async () => { + const store = configureStore({ + reducer: { connectors: connectorsReducer }, + }); + await act(async () => { + root.render( + + + , + ); + }); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/frontend/src/connectors/SignInAgainNotice.tsx b/frontend/src/connectors/SignInAgainNotice.tsx new file mode 100644 index 00000000..5f0fea7a --- /dev/null +++ b/frontend/src/connectors/SignInAgainNotice.tsx @@ -0,0 +1,89 @@ +import { TriangleAlert } from 'lucide-react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import { Alert, AlertDescription } from '../components/ui/alert'; +import { Button } from '../components/ui/button'; +import { selectConnectorCatalog } from './connectorsSlice'; +import { reconnectsInPlace } from './launchRules'; +import useConnectorLauncher from './useConnectorLauncher'; + +type ConnectionRef = { id: string; connector_key: string; name: string }; + +/** + * Reconnects a connection from wherever it shows up broken: in place when the + * wizard can redo its sign-in, otherwise on its connector page. Render + * `modals` somewhere that stays mounted (not inside a closing popover). + */ +export function useSignInAgain({ + onConnected, +}: { onConnected?: () => void } = {}) { + const catalog = useSelector(selectConnectorCatalog); + const navigate = useNavigate(); + const { launch, modals } = useConnectorLauncher({ onConnected }); + + const reconnect = useCallback( + (connection: Omit, mcpToolId?: string) => { + const connector = catalog.find((c) => c.key === connection.connector_key); + if (connector && reconnectsInPlace(connector)) { + launch(connector, { + mode: 'reconnect', + connectionId: connection.id, + // An MCP preset re-signs its existing tool rather than adding one. + mcpServer: mcpToolId ? { id: mcpToolId } : undefined, + }); + return; + } + navigate( + `/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`, + ); + }, + [catalog, launch, navigate], + ); + + return { reconnect, modals }; +} + +/** + * One line per connection that needs signing in again, each with Reconnect. + * Sits in the tool pickers so a broken service is visible where it is used. + */ +export default function SignInAgainNotice({ + connections, + onReconnect, +}: { + connections: ConnectionRef[]; + onReconnect: (connection: ConnectionRef) => void; +}) { + const { t } = useTranslation(); + if (connections.length === 0) return null; + return ( +
+ {connections.map((connection) => ( + + + + + {t('settings.connectors.health.pickerNotice', { + name: connection.name, + interpolation: { escapeValue: false }, + })} + + + + + ))} +
+ ); +}