Show one account at a time on a connector's page

With several accounts of a service, a dropdown picks the one shown along
with its knowledge and tools, instead of listing every account. The page
opens on the account behind the tool it was opened from, else one that
needs signing in again.
This commit is contained in:
arc53-machine committed 2026-09-29 12:07:47 +01:00
1 parent e29ea53837
commit 01caae80ad
11 files changed
+231 -37

No files matched your search

@@ -82,7 +82,7 @@ describe('ConnectionDrawer', () => {
let container: HTMLDivElement;
let root: Root;
let store: ReturnType<typeof makeStore>;
const makeStore = () =>
const makeStore = (extra: Record<string, unknown>[] = []) =>
configureStore({
reducer: {
connectors: connectorsReducer,
@@ -103,6 +103,7 @@ describe('ConnectionDrawer', () => {
status: 'connected',
},
{ id: 'conn-gh', connector_key: 'github', status: 'connected' },
...extra,
],
},
preference: { token: null },
@@ -125,13 +126,20 @@ describe('ConnectionDrawer', () => {
container.remove();
});
const render = async (connector: ConnectorDefinition = DRIVE) => {
store = makeStore();
const render = async (
connector: ConnectorDefinition = DRIVE,
options: {
extra?: Record<string, unknown>[];
initialConnectionId?: string;
} = {},
) => {
store = makeStore(options.extra);
await act(async () => {
root.render(
<Provider store={store}>
<ConnectionDrawer
connector={connector}
initialConnectionId={options.initialConnectionId}
onClose={vi.fn()}
onConnect={vi.fn()}
/>
@@ -145,6 +153,54 @@ describe('ConnectionDrawer', () => {
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((b) => b.textContent === text)!;
it('shows one account at a time, switched from a dropdown', async () => {
connectors.getConnection.mockImplementation(async (id: string) => ({
success: true,
connection:
id === 'conn-2'
? { ...DETAIL, id: 'conn-2', account_name: 'Max' }
: { ...DETAIL, account_name: 'Lena' },
}));
await render(DRIVE, {
extra: [
{ id: 'conn-2', connector_key: 'google_drive', status: 'connected' },
],
initialConnectionId: 'conn-2',
});
const text = () => document.body.textContent ?? '';
// The account the panel was opened for, and only that one.
expect(text()).toContain('Max');
expect(text()).not.toContain('Lena');
const trigger = document.body.querySelector<HTMLElement>(
'[aria-label="settings.connectors.detail.accountPicker"]',
)!;
await act(async () => {
trigger.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
button: 0,
pointerType: 'mouse',
}),
);
});
const other = Array.from(
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
).find((o) => o.textContent?.includes('Lena'))!;
await act(async () => other.click());
expect(text()).toContain('Lena');
expect(text()).not.toContain('Max');
});
it('has no account dropdown with a single account', async () => {
await render();
expect(
document.body.querySelector(
'[aria-label="settings.connectors.detail.accountPicker"]',
),
).toBeNull();
});
it('turns a tool on and off from its connection page', async () => {
users.updateToolStatus.mockResolvedValue({ ok: true });
await render();
+140 -19
View File
@@ -26,6 +26,13 @@ import { ListRow, ListRows } from '../components/ui/list-row';
import { LoadingState } from '../components/ui/loading-state';
import { Modal, ModalActions } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { SettingRow, SettingRows } from '../components/ui/setting-row';
import { Switch } from '../components/ui/switch';
import {
@@ -44,7 +51,11 @@ import { ACCOUNT_NAME_MAX } from './accounts';
import { CapabilityBadges } from './ConnectorCard';
import ConnectorIcon from './ConnectorIcon';
import ConnectorSetupNotice from './ConnectorSetupNotice';
import { loadConnectors, selectConnections } from './connectorsSlice';
import {
connectionNeedsSignIn,
loadConnectors,
selectConnections,
} from './connectorsSlice';
import { connectorDescription, connectorName, isKeyHint } from './i18n';
import ToolPermissions from './ToolPermissions';
import type {
@@ -354,6 +365,58 @@ function WritesSwitch({
);
}
/**
* Which of several accounts of a service the panel shows. The rest of the
* panel is that one account: its knowledge and its tools.
*/
function AccountPicker({
accounts,
value,
onChange,
}: {
accounts: ConnectionDetail[];
value: string;
onChange: (id: string) => void;
}) {
const { t } = useTranslation();
const accountTitle = useAccountTitle();
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
className="w-full"
aria-label={t('settings.connectors.detail.accountPicker')}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.account_name || accountTitle(account)}
{connectionNeedsSignIn(account) &&
` · ${t('settings.connectors.health.signInAgain')}`}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/**
* The account to show from ``accounts``: the one picked, else one that needs
* signing in again (so a problem is not hidden behind another account), else
* the first.
*/
function shownAccount(
accounts: ConnectionDetail[],
picked: string | undefined,
): ConnectionDetail | undefined {
return (
accounts.find((account) => account.id === picked) ??
accounts.find(connectionNeedsSignIn) ??
accounts[0]
);
}
function AccountSection({
connector,
detail,
@@ -648,12 +711,15 @@ function AccountSection({
export default function ConnectionDrawer({
connector,
parts = [],
initialConnectionId,
onClose,
onConnect,
}: {
connector: ConnectorDefinition | null;
/** The same service offered another way, shown here (Jira & Confluence). */
parts?: ConnectorDefinition[];
/** The account to show first, e.g. the one behind the tool it opened from. */
initialConnectionId?: string;
onClose: () => void;
onConnect: (connector: ConnectorDefinition, options?: LaunchOptions) => void;
}) {
@@ -670,6 +736,16 @@ export default function ConnectionDrawer({
);
const [toRemove, setToRemove] = useState<ConnectionDetail | null>(null);
const [toRename, setToRename] = useState<ConnectionDetail | null>(null);
// The account shown per service (the connector and each of its parts).
const [picked, setPicked] = useState<Record<string, string>>({});
useEffect(() => {
setPicked({});
}, [connector?.key, initialConnectionId]);
const pickedIn = (key: string, accounts: ConnectionDetail[]) =>
picked[key] ??
(accounts.some((account) => account.id === initialConnectionId)
? initialConnectionId
: undefined);
const keys = [connector?.key, ...parts.map((part) => part.key)];
const accountIds = connections
@@ -960,7 +1036,29 @@ export default function ConnectionDrawer({
/>
) : (
<div className="flex flex-col gap-6">
{ownDetails.map((detail) => (
{ownDetails.length > 1 && (
<AccountPicker
accounts={ownDetails}
value={
shownAccount(
ownDetails,
pickedIn(connector.key, ownDetails),
)!.id
}
onChange={(id) =>
setPicked((state) => ({
...state,
[connector.key]: id,
}))
}
/>
)}
{[
shownAccount(
ownDetails,
pickedIn(connector.key, ownDetails),
)!,
].map((detail) => (
<AccountSection
key={detail.id}
connector={connector}
@@ -1011,25 +1109,48 @@ export default function ConnectionDrawer({
}
/>
<CapabilityBadges capabilities={part.capabilities} />
{partDetails.map((detail) => (
<AccountSection
key={detail.id}
connector={part}
detail={detail}
onReconnect={reconnect}
onDisconnect={setToDisconnect}
onRemove={setToRemove}
onRename={setToRename}
onSyncMore={(d) =>
onConnect(part, { mode: 'sync', connectionId: d.id })
{partDetails.length > 1 && (
<AccountPicker
accounts={partDetails}
value={
shownAccount(
partDetails,
pickedIn(part.key, partDetails),
)!.id
}
onChange={(id) =>
setPicked((state) => ({ ...state, [part.key]: id }))
}
onRefreshTools={refreshTools}
onToggleTool={toggleTool}
onSyncNow={syncNow}
onAddTools={addTools}
onSwitchWrites={switchWrites}
/>
))}
)}
{partDetails
.filter(
(detail) =>
detail.id ===
shownAccount(
partDetails,
pickedIn(part.key, partDetails),
)?.id,
)
.map((detail) => (
<AccountSection
key={detail.id}
connector={part}
detail={detail}
onReconnect={reconnect}
onDisconnect={setToDisconnect}
onRemove={setToRemove}
onRename={setToRename}
onSyncMore={(d) =>
onConnect(part, { mode: 'sync', connectionId: d.id })
}
onRefreshTools={refreshTools}
onToggleTool={toggleTool}
onSyncNow={syncNow}
onAddTools={addTools}
onSwitchWrites={switchWrites}
/>
))}
</section>
);
})}
+2 -1
View File
@@ -1202,7 +1202,8 @@
"addTools": "Werkzeuge hinzufügen",
"noTools": "Noch keine Werkzeuge aus dieser Verbindung.",
"addToolsFailed": "Die Werkzeuge konnten nicht hinzugefügt werden. Versuche es erneut.",
"rename": "Umbenennen"
"rename": "Umbenennen",
"accountPicker": "Konto auswählen"
},
"permission": {
"always": "Erlauben",
+2 -1
View File
@@ -1208,7 +1208,8 @@
"addTools": "Add tools",
"noTools": "No tools from this connection yet.",
"addToolsFailed": "Could not add the tools. Try again.",
"rename": "Rename"
"rename": "Rename",
"accountPicker": "Choose an account"
},
"permission": {
"always": "Allow",
+2 -1
View File
@@ -1202,7 +1202,8 @@
"addTools": "Añadir herramientas",
"noTools": "Todavía no hay herramientas de esta conexión.",
"addToolsFailed": "No se pudieron añadir las herramientas. Inténtalo de nuevo.",
"rename": "Cambiar nombre"
"rename": "Cambiar nombre",
"accountPicker": "Elegir una cuenta"
},
"permission": {
"always": "Permitir",
+2 -1
View File
@@ -1193,7 +1193,8 @@
"addTools": "ツールを追加",
"noTools": "この接続のツールはまだありません。",
"addToolsFailed": "ツールを追加できませんでした。もう一度お試しください。",
"rename": "名前を変更"
"rename": "名前を変更",
"accountPicker": "アカウントを選択"
},
"permission": {
"always": "許可",
+2 -1
View File
@@ -1258,7 +1258,8 @@
"addTools": "Добавить инструменты",
"noTools": "У этого подключения пока нет инструментов.",
"addToolsFailed": "Не удалось добавить инструменты. Попробуйте ещё раз.",
"rename": "Переименовать"
"rename": "Переименовать",
"accountPicker": "Выберите аккаунт"
},
"permission": {
"always": "Разрешать",
+2 -1
View File
@@ -1193,7 +1193,8 @@
"addTools": "新增工具",
"noTools": "此連線還沒有工具。",
"addToolsFailed": "無法新增工具。請再試一次。",
"rename": "重新命名"
"rename": "重新命名",
"accountPicker": "選擇帳號"
},
"permission": {
"always": "允許",
+2 -1
View File
@@ -1193,7 +1193,8 @@
"addTools": "添加工具",
"noTools": "此连接还没有工具。",
"addToolsFailed": "无法添加工具。请重试。",
"rename": "重命名"
"rename": "重命名",
"accountPicker": "选择账号"
},
"permission": {
"always": "允许",
+11 -3
View File
@@ -106,8 +106,16 @@ vi.mock('../api/services/connectorsService', () => ({
// The connector panel itself is tested on its own; here it only has to open
// for the right service.
vi.mock('../connectors/ConnectionDrawer', () => ({
default: ({ connector }: { connector: { key: string } | null }) =>
connector ? <div data-testid="drawer">{connector.key}</div> : null,
default: ({
connector,
initialConnectionId,
}: {
connector: { key: string } | null;
initialConnectionId?: string;
}) =>
connector ? (
<div data-testid="drawer">{`${connector.key}:${initialConnectionId}`}</div>
) : null,
}));
vi.mock('../connectors/useConnectorLauncher', () => ({
default: () => ({ launch: vi.fn(), modals: null }),
@@ -194,7 +202,7 @@ describe('Tools page', () => {
);
expect(
document.body.querySelector('[data-testid="drawer"]')?.textContent,
).toBe('telegram');
).toBe('telegram:conn-1');
// Stays on the Tools page.
expect(document.body.querySelector('[data-testid="where"]')).toBeNull();
});
+7 -5
View File
@@ -76,9 +76,10 @@ export default function Tools() {
null,
);
// The connection panel, opened here for a connected tool's service.
const [managed, setManaged] = React.useState<ConnectorDefinition | null>(
null,
);
const [managed, setManaged] = React.useState<{
connector: ConnectorDefinition;
connectionId: string;
} | null>(null);
const [mcpStatuses, setMcpStatuses] = React.useState<{
[toolId: string]: string;
}>({});
@@ -262,7 +263,7 @@ export default function Tools() {
const connection = connectionOf(tool);
if (connection) {
const connector = catalog.find((c) => c.key === connection.connector_key);
if (connector) setManaged(connector);
if (connector) setManaged({ connector, connectionId: connection.id });
else
navigate(
`/settings/connectors?connector=${encodeURIComponent(connection.connector_key)}`,
@@ -607,7 +608,8 @@ export default function Tools() {
}}
/>
<ConnectionDrawer
connector={managed}
connector={managed?.connector ?? null}
initialConnectionId={managed?.connectionId}
onClose={() => {
setManaged(null);
getUserTools();