mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 14:15:52 +00:00
Show connections that need signing in again where their tools are picked
The chat and agent tool pickers mark a tool whose connection expired and offer Reconnect, which redoes the sign-in in place (or opens the connector page for a custom server). The connector page names the account without claiming it is connected and says what went wrong in plain words, keeping the provider's message on hover.
This commit is contained in:
1 parent
97e37a7e37
commit
5c2a45fb74
9 files changed
+478
-54
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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<File | null>(null);
|
||||
const [userTools, setUserTools] = useState<MultiSelectPopoverItem[]>([]);
|
||||
const [rawUserTools, setRawUserTools] = useState<UserToolType[]>([]);
|
||||
// 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<Model[]>([]);
|
||||
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 = (
|
||||
<p className="text-warning text-xs">
|
||||
{t('settings.connectors.health.signInAgain')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
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' && <WorkflowBuilder />}
|
||||
<AgentPageToolbar
|
||||
intro={agent.id ? undefined : t('agents.form.byline.new')}
|
||||
@@ -1120,6 +1156,24 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
|
||||
'agents.form.toolsPopup.searchPlaceholder',
|
||||
)}
|
||||
emptyMessage={t('agents.form.toolsPopup.noOptionsMessage')}
|
||||
footer={
|
||||
brokenToolConnections.length > 0 ? (
|
||||
<SignInAgainNotice
|
||||
connections={brokenToolConnections.map(
|
||||
({ connection }) => connection,
|
||||
)}
|
||||
onReconnect={(connection) => {
|
||||
setIsToolsPopupOpen(false);
|
||||
signInAgain.reconnect(
|
||||
connection,
|
||||
brokenToolConnections.find(
|
||||
(entry) => entry.connection.id === connection.id,
|
||||
)?.mcpToolId,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
trigger={
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,10 @@ vi.mock('react-i18next', () => ({
|
||||
}));
|
||||
// The upload modal pulls in the whole ingest UI; the composer never opens it here.
|
||||
vi.mock('../upload/Upload', () => ({ default: () => null }));
|
||||
vi.mock('../connectors/SignInAgainNotice', () => ({
|
||||
default: () => null,
|
||||
useSignInAgain: () => ({ reconnect: vi.fn(), modals: null }),
|
||||
}));
|
||||
|
||||
import connectorsReducer from '../connectors/connectorsSlice';
|
||||
import notificationsReducer from '../notifications/notificationsSlice';
|
||||
|
||||
@@ -44,9 +44,13 @@ import { type MultiSelectPopoverItem } from './MultiSelectPopover';
|
||||
import ToolIcon from './ToolIcon';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import {
|
||||
connectionNeedsSignIn,
|
||||
loadConnectors,
|
||||
selectConnections,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import SignInAgainNotice, {
|
||||
useSignInAgain,
|
||||
} from '../connectors/SignInAgainNotice';
|
||||
import {
|
||||
AttachFileButton,
|
||||
AttachmentChipList,
|
||||
@@ -1566,6 +1570,10 @@ export default function MessageInput({
|
||||
.finally(() => setToolsLoading(false));
|
||||
}, [token]);
|
||||
|
||||
// Launched from the Tools picker; the modals live here because the picker
|
||||
// closes when a sign-in opens.
|
||||
const signInAgain = useSignInAgain({ onConnected: fetchUserTools });
|
||||
|
||||
useEffect(() => {
|
||||
if (isToolsPopupOpen) {
|
||||
fetchUserTools();
|
||||
@@ -1605,8 +1613,25 @@ export default function MessageInput({
|
||||
? t('agents.form.toolsPopup.groupCustom')
|
||||
: t('settings.tools.groupBuiltIn')))
|
||||
: undefined,
|
||||
descriptionNode: connectionNeedsSignIn(connection) ? (
|
||||
<p className="text-warning text-xs">
|
||||
{t('settings.connectors.health.signInAgain')}
|
||||
</p>
|
||||
) : undefined,
|
||||
};
|
||||
});
|
||||
// Each broken connection once, with the tool it would re-sign (an MCP
|
||||
// preset keeps its tool rather than gaining a second one).
|
||||
const brokenConnections = connections
|
||||
.filter(connectionNeedsSignIn)
|
||||
.flatMap((connection) => {
|
||||
const tools = userTools.filter(
|
||||
(tool) => tool.connection_id === connection.id,
|
||||
);
|
||||
if (tools.length === 0) return [];
|
||||
const mcpTool = tools.find((tool) => tool.name === 'mcp_tool');
|
||||
return [{ connection, mcpToolId: mcpTool?.id }];
|
||||
});
|
||||
|
||||
const selectedToolIds = userTools
|
||||
.filter((tool) => tool.status)
|
||||
@@ -1747,6 +1772,7 @@ export default function MessageInput({
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} className="flex w-full flex-col">
|
||||
{signInAgain.modals}
|
||||
{/* react-dropzone input (for drag/drop) */}
|
||||
<input {...getInputProps()} />
|
||||
<input
|
||||
@@ -1850,6 +1876,22 @@ export default function MessageInput({
|
||||
selectedIds={selectedToolIds}
|
||||
onToggle={handleToggleTool}
|
||||
loading={toolsLoading}
|
||||
notice={
|
||||
<SignInAgainNotice
|
||||
connections={brokenConnections.map(
|
||||
({ connection }) => connection,
|
||||
)}
|
||||
onReconnect={(connection) => {
|
||||
setIsToolsPopupOpen(false);
|
||||
signInAgain.reconnect(
|
||||
connection,
|
||||
brokenConnections.find(
|
||||
(entry) => entry.connection.id === connection.id,
|
||||
)?.mcpToolId,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{ENABLE_VOICE_INPUT && sttAvailable && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowRight, Wrench } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@@ -15,6 +16,8 @@ type ToolsTriggerProps = {
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
loading: boolean;
|
||||
/** Shown above the links, e.g. connections that need signing in again. */
|
||||
notice?: ReactNode;
|
||||
};
|
||||
|
||||
export default function ToolsTrigger({
|
||||
@@ -24,6 +27,7 @@ export default function ToolsTrigger({
|
||||
selectedIds,
|
||||
onToggle,
|
||||
loading,
|
||||
notice,
|
||||
}: ToolsTriggerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -39,19 +43,22 @@ export default function ToolsTrigger({
|
||||
emptyMessage={t('settings.tools.noToolsFound')}
|
||||
loading={loading}
|
||||
footer={
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<Link to="/settings/tools">
|
||||
{t('settings.tools.manageTools')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<Link to="/settings/connectors?capability=tools">
|
||||
{t('conversation.sources.connectMore')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3">
|
||||
{notice}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<Link to="/settings/tools">
|
||||
{t('settings.tools.manageTools')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="link" size="inline" asChild>
|
||||
<Link to="/settings/connectors?capability=tools">
|
||||
{t('conversation.sources.connectMore')}
|
||||
<ArrowRight aria-hidden="true" className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
trigger={
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card variant="subtle" padding="none">
|
||||
<ListRows>
|
||||
<ListRow
|
||||
title={accountTitle(detail)}
|
||||
description={
|
||||
detail.last_error ? (
|
||||
<span title={detail.last_error}>{detail.last_error}</span>
|
||||
) : undefined
|
||||
}
|
||||
trailing={
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANT[detail.status]}>
|
||||
{t(`settings.connectors.connectionStatus.${detail.status}`)}
|
||||
</Badge>
|
||||
{detail.status !== 'connected' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
variant="outline"
|
||||
onClick={() => onReconnect(detail)}
|
||||
>
|
||||
<RefreshCw />
|
||||
{t('settings.connectors.status.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionMenu
|
||||
triggerLabel={t('settings.connectors.detail.accountMenu')}
|
||||
options={menu}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</ListRows>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card variant="subtle" padding="none">
|
||||
<ListRows>
|
||||
<ListRow
|
||||
title={accountTitle(detail)}
|
||||
description={
|
||||
!broken && detail.last_error ? (
|
||||
<span title={detail.last_error}>{detail.last_error}</span>
|
||||
) : undefined
|
||||
}
|
||||
trailing={
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={STATUS_VARIANT[detail.status]}>
|
||||
{t(`settings.connectors.connectionStatus.${detail.status}`)}
|
||||
</Badge>
|
||||
{!broken && detail.status !== 'connected' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
variant="outline"
|
||||
onClick={() => onReconnect(detail)}
|
||||
>
|
||||
<RefreshCw />
|
||||
{t('settings.connectors.status.reconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionMenu
|
||||
triggerLabel={t('settings.connectors.detail.accountMenu')}
|
||||
options={menu}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</ListRows>
|
||||
</Card>
|
||||
{broken && (
|
||||
<Alert variant="warning">
|
||||
<TriangleAlert />
|
||||
{/* The provider's own message stays on hover, for debugging. */}
|
||||
<AlertDescription title={detail.last_error ?? undefined}>
|
||||
{problem(detail)}
|
||||
</AlertDescription>
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
variant="outline"
|
||||
onClick={() => onReconnect(detail)}
|
||||
>
|
||||
<RefreshCw />
|
||||
{t('settings.connectors.status.reconnect')}
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
{canSync && (
|
||||
<section className="flex flex-col gap-2">
|
||||
<SectionHeader
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
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 (
|
||||
<div data-testid="where">
|
||||
{useLocation().pathname + useLocation().search}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({
|
||||
connectorKey,
|
||||
mcpToolId,
|
||||
}: {
|
||||
connectorKey: string;
|
||||
mcpToolId?: string;
|
||||
}) {
|
||||
const { reconnect, modals } = useSignInAgain();
|
||||
return (
|
||||
<>
|
||||
<SignInAgainNotice
|
||||
connections={[
|
||||
{ id: 'conn-2', connector_key: connectorKey, name: 'Linear' },
|
||||
]}
|
||||
onReconnect={(connection) => 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<typeof configureStore>[0]);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Harness connectorKey={connectorKey} mcpToolId={mcpToolId} />
|
||||
}
|
||||
/>
|
||||
<Route path="/settings/connectors" element={<Where />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<SignInAgainNotice connections={[]} onReconnect={vi.fn()} />
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -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<ConnectionRef, 'name'>, 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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
{connections.map((connection) => (
|
||||
<Alert key={connection.id} variant="warning">
|
||||
<TriangleAlert />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span className="mr-3 min-w-0">
|
||||
{t('settings.connectors.health.pickerNotice', {
|
||||
name: connection.name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
shape="pill"
|
||||
className="shrink-0"
|
||||
onClick={() => onReconnect(connection)}
|
||||
>
|
||||
{t('settings.connectors.status.reconnect')}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user