diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 7a6b9739..a26fe87a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -889,7 +889,8 @@ also refuses Logs, Schedules and Pin on a draft), never on `ownership`, (`components/ViewOnlyNotice`: an `Alert role="note"` with `Lock` first and `common.viewOnlyNotice`) as its first child. Where only part of an editable form is locked (a tool's credentials when the owner turned off "Editors can - change credentials"), the same notice passes its own `message`. There is no Save; Cancel becomes a lone Close. + change credentials", or when the tool runs on the owner's connection, whose + secret only the owner changes), the same notice passes its own `message`. There is no Save; Cancel becomes a lone Close. Fields are `disabled` (a group: `
`); long text the user reads or copies (a prompt, a chunk) is `readOnly`, so it keeps full contrast and scrolls. Titles and menu items swap Edit and diff --git a/frontend/src/connectors/ConnectionDrawer.test.tsx b/frontend/src/connectors/ConnectionDrawer.test.tsx index 6a6e680b..04aa9dad 100644 --- a/frontend/src/connectors/ConnectionDrawer.test.tsx +++ b/frontend/src/connectors/ConnectionDrawer.test.tsx @@ -201,11 +201,25 @@ describe('ConnectionDrawer', () => { ).toBeNull(); }); + // The switch is the owner's "In my chats", the same one as on the tool's + // card, not an on/off for everyone the tool is shared with. + it('labels the tool switch "In my chats"', async () => { + await render(); + const toggle = document.body.querySelector( + '[aria-label="settings.tools.useInMyChatsAria"]', + )!; + expect(toggle.getAttribute('role')).toBe('switch'); + const label = document.body.querySelector( + `label[for="${toggle.id}"]`, + ); + expect(label?.textContent).toBe('settings.tools.inMyChats'); + }); + it('turns a tool on and off from its connection page', async () => { users.updateToolStatus.mockResolvedValue({ ok: true }); await render(); const toggle = document.body.querySelector( - '[aria-label="settings.connectors.detail.toolSwitch"]', + '[aria-label="settings.tools.useInMyChatsAria"]', )!; expect(toggle.getAttribute('aria-checked')).toBe('true'); await act(async () => toggle.click()); @@ -220,7 +234,7 @@ describe('ConnectionDrawer', () => { users.updateToolStatus.mockResolvedValue({ ok: false }); await render(); const toggle = document.body.querySelector( - '[aria-label="settings.connectors.detail.toolSwitch"]', + '[aria-label="settings.tools.useInMyChatsAria"]', )!; await act(async () => toggle.click()); expect(toggle.getAttribute('aria-checked')).toBe('true'); diff --git a/frontend/src/connectors/ConnectionDrawer.tsx b/frontend/src/connectors/ConnectionDrawer.tsx index fa15ad8d..4c74938b 100644 --- a/frontend/src/connectors/ConnectionDrawer.tsx +++ b/frontend/src/connectors/ConnectionDrawer.tsx @@ -22,6 +22,7 @@ import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu'; import { EmptyState } from '../components/ui/empty-state'; import { FormField } from '../components/ui/form-field'; import { Input } from '../components/ui/input'; +import { Label } from '../components/ui/label'; import { ListRow, ListRows } from '../components/ui/list-row'; import { LoadingState } from '../components/ui/loading-state'; import { Modal, ModalActions } from '../components/ui/modal'; @@ -302,7 +303,11 @@ function RenameAccountModal({ ); } -/** A connection tool's on/off switch: the one place it is turned on or off. */ +/** + * A connection tool's "In my chats" switch: the owner's own preference, the + * same one as on the tool's card. Teammates the tool is shared with keep + * their own, so it never turns the tool off for them. + */ function ToolSwitch({ tool, onToggle, @@ -313,19 +318,26 @@ function ToolSwitch({ const { t } = useTranslation(); const [on, setOn] = useState(tool.status); useEffect(() => setOn(tool.status), [tool.status]); + const id = `connection-tool-in-chats-${tool.id}`; return ( - { - const next = checked === true; - setOn(next); - if (!(await onToggle(tool.id, next))) setOn(!next); - }} - /> + + + { + const next = checked === true; + setOn(next); + if (!(await onToggle(tool.id, next))) setOn(!next); + }} + /> + ); } diff --git a/frontend/src/connectors/toolService.test.ts b/frontend/src/connectors/toolService.test.ts new file mode 100644 index 00000000..a376766e --- /dev/null +++ b/frontend/src/connectors/toolService.test.ts @@ -0,0 +1,127 @@ +import { toolServiceOf } from './toolService'; +import type { Connection, ConnectorDefinition } from './types'; + +const connection = (overrides: Partial): Connection => + ({ + id: 'conn-1', + connector_key: 'telegram', + name: 'Telegram', + icon: 'tool_telegram', + account_label: '…abcd', + status: 'connected', + ...overrides, + }) as Connection; + +const connector = ( + overrides: Partial, +): ConnectorDefinition => + ({ + key: 'telegram', + name: 'Telegram', + icon: 'tool_telegram', + publisher: 'built_in', + tool_templates: ['telegram'], + mcp_url: null, + credential_policy: 'choose', + ...overrides, + }) as ConnectorDefinition; + +const CATALOG = [ + connector({}), + connector({ + key: 'linear', + name: 'Linear', + icon: 'linear', + publisher: 'preset', + tool_templates: ['mcp_tool'], + mcp_url: 'https://mcp.linear.app/mcp', + }), + connector({ + key: 'custom_mcp', + name: 'MCP server', + icon: 'tool_mcp_tool', + publisher: 'custom', + tool_templates: ['mcp_tool'], + }), + connector({ + key: 'custom_openapi', + name: 'OpenAPI / REST', + icon: 'tool_api_tool', + publisher: 'custom', + tool_templates: ['api_tool'], + }), +]; + +describe('toolServiceOf', () => { + it('is undefined for a tool with no connection', () => { + expect( + toolServiceOf( + { name: 'api_tool', displayName: 'My API', connection_id: null }, + [], + CATALOG, + ), + ).toBeUndefined(); + }); + + it("uses the caller's own connection when it is loaded", () => { + const own = connection({}); + expect( + toolServiceOf( + { name: 'telegram', displayName: 'Telegram', connection_id: 'conn-1' }, + [own], + CATALOG, + ), + ).toEqual({ + name: 'Telegram', + icon: 'tool_telegram', + connection: own, + connector: CATALOG[0], + }); + }); + + it("names a teammate's built-in service tool after its connector", () => { + const service = toolServiceOf( + { + name: 'telegram', + displayName: 'Telegram · Alerts bot', + connection_id: 'owner-conn', + }, + [], + CATALOG, + ); + expect(service).toEqual({ + name: 'Telegram', + icon: 'tool_telegram', + connector: CATALOG[0], + }); + }); + + it("matches a teammate's MCP preset by its server", () => { + const service = toolServiceOf( + { + name: 'mcp_tool', + displayName: 'Linear', + connection_id: 'owner-conn', + config: { server_url: 'https://mcp.linear.app/sse' }, + }, + [], + CATALOG, + ); + expect(service?.name).toBe('Linear'); + expect(service?.icon).toBe('linear'); + }); + + it("groups a teammate's custom server under its own name, without an account", () => { + const service = toolServiceOf( + { + name: 'mcp_tool', + displayName: 'Carrier Rates · ops@x', + connection_id: 'owner-conn', + config: { server_url: 'https://rates.example.com/mcp' }, + }, + [], + CATALOG, + ); + expect(service).toEqual({ name: 'Carrier Rates', icon: null }); + }); +}); diff --git a/frontend/src/connectors/toolService.ts b/frontend/src/connectors/toolService.ts new file mode 100644 index 00000000..898ebbf8 --- /dev/null +++ b/frontend/src/connectors/toolService.ts @@ -0,0 +1,99 @@ +import type { Connection, ConnectorDefinition } from './types'; + +/** The service a connected tool comes from, as the pickers group it. */ +export type ToolService = { + name: string; + /** A `ConnectorIcon` name; null draws the tool's own icon. */ + icon: string | null; + /** Set only when the connection is the caller's own. */ + connection?: Connection; + connector?: ConnectorDefinition; +}; + +type ToolLike = { + name: string; + displayName?: string | null; + customName?: string | null; + connection_id?: string | null; + config?: unknown; +}; + +// Templates any server can use (an MCP server, an OpenAPI spec): a tool +// made from one belongs to its connection, not to a connector. Mirrors +// `_GENERIC_TOOL_TEMPLATES` in `docsgpt/connectors/catalog.py`. +const GENERIC_TEMPLATES = new Set(['mcp_tool', 'api_tool']); + +// `scheme://host`, the part of an MCP URL a connection is keyed on. +const baseUrl = (url: unknown): string => { + if (typeof url !== 'string' || !url) return ''; + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return ''; + } +}; + +// The catalog connector a tool was made from, found from the tool alone: a +// built-in service by its tool template, an MCP preset by its server. +const connectorForTool = ( + tool: ToolLike, + catalog: ConnectorDefinition[], +): ConnectorDefinition | undefined => { + if (!GENERIC_TEMPLATES.has(tool.name)) { + return catalog.find( + (c) => + c.publisher === 'built_in' && c.tool_templates?.includes(tool.name), + ); + } + if (tool.name !== 'mcp_tool') return undefined; + const server = baseUrl( + (tool.config as { server_url?: unknown } | undefined)?.server_url, + ); + if (!server) return undefined; + return catalog.find( + (c) => + c.publisher !== 'custom' && !!c.mcp_url && baseUrl(c.mcp_url) === server, + ); +}; + +/** + * The service a tool with a connection belongs to, for grouping it in the + * tool pickers. The caller's own connection names it; a teammate's + * connection is never in the caller's list, so a shared tool is named from + * the catalog, or failing that from its own name without the " · account" + * the server adds. Undefined for a tool with no connection. + * + * Args: + * tool: The tool, as `/api/get_tools` returns it. + * connections: The caller's own connections. + * catalog: The connector catalog. + * + * Returns: + * The group name and icon, or undefined. + */ +export function toolServiceOf( + tool: ToolLike, + connections: Connection[], + catalog: ConnectorDefinition[], +): ToolService | undefined { + if (!tool.connection_id) return undefined; + const connection = connections.find((c) => c.id === tool.connection_id); + if (connection) { + const connector = catalog.find((c) => c.key === connection.connector_key); + return { + name: connection.name, + icon: connection.icon, + connection, + ...(connector && { connector }), + }; + } + const connector = connectorForTool(tool, catalog); + if (connector) { + return { name: connector.name, icon: connector.icon, connector }; + } + const own = (tool.displayName || tool.customName || tool.name).split( + ' · ', + )[0]; + return { name: own, icon: null }; +} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index e16c1e58..118efab8 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -805,7 +805,8 @@ }, "viewers_can_use_in_agents": { "label": "Betrachter dürfen es in eigenen Agenten nutzen", - "description": "Es läuft mit deinen Anmeldedaten." + "description": "Es läuft mit deinen Anmeldedaten.", + "descriptionMember": "Jedes Mitglied nutzt sein eigenes Konto." } }, "prompt": { @@ -1401,7 +1402,6 @@ "syncNow": "Jetzt synchronisieren", "syncStarted": "{{name}} wird synchronisiert…", "syncFailed": "Die Synchronisierung konnte nicht gestartet werden. Versuche es erneut.", - "toolSwitch": "{{name}} an", "toolSwitchFailed": "Das Werkzeug konnte nicht geändert werden. Versuche es erneut.", "account": "Konto: {{account}}", "expired": "Die Anmeldung ist abgelaufen. Verbinde neu, um diese Verbindung weiter zu nutzen.", @@ -1535,9 +1535,12 @@ "share": { "heading": "Welches Konto Teammitglieder nutzen", "ownerWarning": "Teammitglieder handeln in {{name}} als {{account}}.", + "ownerWarningShared": "Teammitglieder handeln mit dem {{name}}-Konto des Eigentümers.", "confirmWrite": "Ich verstehe, dass Teammitglieder mit meinem Konto Aktionen ausführen können.", + "confirmWriteShared": "Ich verstehe, dass Teammitglieder mit dem {{name}}-Konto des Eigentümers Aktionen ausführen können.", "memberNote": "Jedes Mitglied verbindet sein eigenes {{name}}-Konto, bevor es dieses Werkzeug nutzen kann.", "forced": "Ein Admin hat das für alle Freigaben dieses Konnektors festgelegt.", + "ownerChooses": "Nur der Eigentümer kann das ändern.", "saveFailed": "Die Änderung konnte nicht gespeichert werden. Versuche es erneut." }, "createFailed": "Der Konnektor konnte nicht hinzugefügt werden. Versuche es erneut.", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 02cfcd07..a076d356 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -811,7 +811,8 @@ }, "viewers_can_use_in_agents": { "label": "Viewers can use it in their own agents", - "description": "It runs with your credentials." + "description": "It runs with your credentials.", + "descriptionMember": "Each member uses their own account." } }, "prompt": { @@ -1407,7 +1408,6 @@ "syncNow": "Sync now", "syncStarted": "Syncing {{name}}…", "syncFailed": "Could not start the sync. Try again.", - "toolSwitch": "{{name}} on", "toolSwitchFailed": "Could not change the tool. Try again.", "account": "Account: {{account}}", "expired": "The sign-in expired. Reconnect to keep using this connection.", @@ -1541,9 +1541,12 @@ "share": { "heading": "Whose account team members use", "ownerWarning": "Team members will act as {{account}} on {{name}}.", + "ownerWarningShared": "Team members will act as the owner's {{name}} account.", "confirmWrite": "I understand teammates can take actions with my account.", + "confirmWriteShared": "I understand teammates can take actions with the owner's {{name}} account.", "memberNote": "Each member connects their own {{name}} account before they can use this tool.", "forced": "An admin chose this for every share of this connector.", + "ownerChooses": "Only the owner can change this.", "saveFailed": "Could not save the change. Try again." }, "createFailed": "Could not add the connector. Try again.", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 49f99675..8047b945 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -805,7 +805,8 @@ }, "viewers_can_use_in_agents": { "label": "Los lectores pueden usarla en sus propios agentes", - "description": "Se ejecuta con tus credenciales." + "description": "Se ejecuta con tus credenciales.", + "descriptionMember": "Cada miembro usa su propia cuenta." } }, "prompt": { @@ -1401,7 +1402,6 @@ "syncNow": "Sincronizar ahora", "syncStarted": "Sincronizando {{name}}…", "syncFailed": "No se pudo iniciar la sincronización. Inténtalo de nuevo.", - "toolSwitch": "{{name}} activado", "toolSwitchFailed": "No se pudo cambiar la herramienta. Inténtalo de nuevo.", "account": "Cuenta: {{account}}", "expired": "El inicio de sesión caducó. Reconecta para seguir usando esta conexión.", @@ -1535,9 +1535,12 @@ "share": { "heading": "Qué cuenta usan los miembros del equipo", "ownerWarning": "Los miembros del equipo actuarán como {{account}} en {{name}}.", + "ownerWarningShared": "Los miembros del equipo actuarán con la cuenta de {{name}} del propietario.", "confirmWrite": "Entiendo que los miembros del equipo pueden realizar acciones con mi cuenta.", + "confirmWriteShared": "Entiendo que los miembros del equipo pueden realizar acciones con la cuenta de {{name}} del propietario.", "memberNote": "Cada miembro conecta su propia cuenta de {{name}} antes de poder usar esta herramienta.", "forced": "Un administrador eligió esto para todos los recursos compartidos de este conector.", + "ownerChooses": "Solo el propietario puede cambiar esto.", "saveFailed": "No se pudo guardar el cambio. Inténtalo de nuevo." }, "createFailed": "No se pudo añadir el conector. Inténtalo de nuevo.", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index d90e3202..1fdb628a 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -804,7 +804,8 @@ }, "viewers_can_use_in_agents": { "label": "閲覧者が自分のエージェントで使える", - "description": "あなたの認証情報で実行されます。" + "description": "あなたの認証情報で実行されます。", + "descriptionMember": "各メンバーが自分のアカウントを使います。" } }, "prompt": { @@ -1400,7 +1401,6 @@ "syncNow": "今すぐ同期", "syncStarted": "{{name}} を同期しています…", "syncFailed": "同期を開始できませんでした。もう一度お試しください。", - "toolSwitch": "{{name}} をオン", "toolSwitchFailed": "ツールを変更できませんでした。もう一度お試しください。", "account": "アカウント: {{account}}", "expired": "サインインの有効期限が切れました。この接続を使い続けるには再接続してください。", @@ -1525,9 +1525,12 @@ "share": { "heading": "チームメンバーが使うアカウント", "ownerWarning": "チームメンバーは {{name}} で {{account}} として操作します。", + "ownerWarningShared": "チームメンバーは所有者の {{name}} アカウントで操作します。", "confirmWrite": "チームメンバーが自分のアカウントで操作を行えることを理解しました。", + "confirmWriteShared": "チームメンバーが所有者の {{name}} アカウントで操作を行えることを理解しました。", "memberNote": "各メンバーは、このツールを使う前に自分の {{name}} アカウントを接続します。", "forced": "このコネクタのすべての共有に対して管理者が設定しています。", + "ownerChooses": "これを変更できるのは所有者だけです。", "saveFailed": "変更を保存できませんでした。もう一度お試しください。" }, "createFailed": "コネクタを追加できませんでした。もう一度お試しください。", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 501a03e8..04d61c1a 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -853,7 +853,8 @@ }, "viewers_can_use_in_agents": { "label": "Читатели могут использовать его в своих агентах", - "description": "Он работает с вашими учётными данными." + "description": "Он работает с вашими учётными данными.", + "descriptionMember": "Каждый участник использует свой аккаунт." } }, "prompt": { @@ -1465,7 +1466,6 @@ "syncNow": "Синхронизировать", "syncStarted": "Синхронизация {{name}}…", "syncFailed": "Не удалось запустить синхронизацию. Попробуйте ещё раз.", - "toolSwitch": "{{name}} включён", "toolSwitchFailed": "Не удалось изменить инструмент. Попробуйте ещё раз.", "account": "Аккаунт: {{account}}", "expired": "Срок входа истёк. Переподключите, чтобы продолжить пользоваться этим подключением.", @@ -1617,9 +1617,12 @@ "share": { "heading": "Чей аккаунт используют участники команды", "ownerWarning": "Участники команды будут действовать в {{name}} как {{account}}.", + "ownerWarningShared": "Участники команды будут действовать от имени аккаунта {{name}} владельца.", "confirmWrite": "Я понимаю, что участники команды могут выполнять действия от имени моего аккаунта.", + "confirmWriteShared": "Я понимаю, что участники команды могут выполнять действия от имени аккаунта {{name}} владельца.", "memberNote": "Каждый участник подключает свой аккаунт {{name}}, прежде чем пользоваться этим инструментом.", "forced": "Администратор задал это для всех общих доступов к этому коннектору.", + "ownerChooses": "Изменить это может только владелец.", "saveFailed": "Не удалось сохранить изменение. Попробуйте ещё раз." }, "createFailed": "Не удалось добавить коннектор. Попробуйте ещё раз.", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 739796b0..dae50b55 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -804,7 +804,8 @@ }, "viewers_can_use_in_agents": { "label": "檢視者可以在自己的代理中使用", - "description": "它會以你的憑證執行。" + "description": "它會以你的憑證執行。", + "descriptionMember": "每位成員使用自己的帳號。" } }, "prompt": { @@ -1400,7 +1401,6 @@ "syncNow": "立即同步", "syncStarted": "正在同步 {{name}}…", "syncFailed": "無法開始同步。請再試一次。", - "toolSwitch": "{{name}} 開啟", "toolSwitchFailed": "無法變更工具。請再試一次。", "account": "帳號:{{account}}", "expired": "登入已過期。重新連線以繼續使用此連線。", @@ -1525,9 +1525,12 @@ "share": { "heading": "團隊成員使用誰的帳號", "ownerWarning": "團隊成員將在 {{name}} 中以 {{account}} 的身分操作。", + "ownerWarningShared": "團隊成員將以擁有者的 {{name}} 帳號操作。", "confirmWrite": "我了解團隊成員可以使用我的帳號執行操作。", + "confirmWriteShared": "我了解團隊成員可以使用擁有者的 {{name}} 帳號執行操作。", "memberNote": "每位成員須先連線自己的 {{name}} 帳號,才能使用此工具。", "forced": "管理員已為此連接器的所有共用設定此項。", + "ownerChooses": "只有擁有者可以變更此項。", "saveFailed": "無法儲存變更。請再試一次。" }, "createFailed": "無法新增連接器。請再試一次。", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 550c7c2f..776a8f62 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -804,7 +804,8 @@ }, "viewers_can_use_in_agents": { "label": "查看者可以在自己的代理中使用", - "description": "它使用你的凭据运行。" + "description": "它使用你的凭据运行。", + "descriptionMember": "每位成员使用自己的账号。" } }, "prompt": { @@ -1400,7 +1401,6 @@ "syncNow": "立即同步", "syncStarted": "正在同步 {{name}}…", "syncFailed": "无法开始同步。请重试。", - "toolSwitch": "{{name}} 开启", "toolSwitchFailed": "无法更改工具。请重试。", "account": "账号:{{account}}", "expired": "登录已过期。重新连接以继续使用此连接。", @@ -1525,9 +1525,12 @@ "share": { "heading": "团队成员使用谁的账号", "ownerWarning": "团队成员将在 {{name}} 中以 {{account}} 的身份操作。", + "ownerWarningShared": "团队成员将以所有者的 {{name}} 账号操作。", "confirmWrite": "我了解团队成员可以使用我的账号执行操作。", + "confirmWriteShared": "我了解团队成员可以使用所有者的 {{name}} 账号执行操作。", "memberNote": "每位成员需先连接自己的 {{name}} 账号才能使用此工具。", "forced": "管理员已为此连接器的所有共享设定了此项。", + "ownerChooses": "只有所有者可以更改此项。", "saveFailed": "无法保存更改。请重试。" }, "createFailed": "无法添加连接器。请重试。", diff --git a/frontend/src/settings/ToolConfig.test.tsx b/frontend/src/settings/ToolConfig.test.tsx index 760d6ff8..4786e102 100644 --- a/frontend/src/settings/ToolConfig.test.tsx +++ b/frontend/src/settings/ToolConfig.test.tsx @@ -585,6 +585,48 @@ describe('ToolConfig', () => { ); }); + // The connection's secret is the owner's alone: the server refuses any + // credential change from anyone else, so the form must not offer one. + it("locks a connected tool's credentials for an editor allowed to change credentials", async () => { + updateTool.mockResolvedValue({ ok: true }); + await render({ + ...configTool, + connection_id: 'owner-conn', + access: 'editor', + allowed_actions: ['edit', 'edit_credentials', 'use'], + } as UserToolType); + const note = container.querySelector('[data-slot="alert"]'); + expect(note?.textContent).toBe('common.credentialsLockedNotice'); + const credential = container.querySelector( + 'input[type="password"]', + ); + expect(credential && disabled(credential)).toBe(true); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set; + setter?.call(nameInput(), 'Renamed'); + nameInput().dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + buttonByText('settings.tools.save')?.click(); + }); + expect(updateTool).toHaveBeenCalledTimes(1); + expect(updateTool.mock.calls[0][0]).not.toHaveProperty('config'); + }); + + it("keeps a connected tool's credentials open to its owner", async () => { + await render({ + ...configTool, + connection_id: 'my-conn', + access: 'owner', + } as UserToolType); + expect(container.textContent).not.toContain( + 'common.credentialsLockedNotice', + ); + }); + it("disables an API tool's URL and header values without edit_credentials", async () => { await render({ ...apiTool, ...editorNoCreds } as APIToolType); await expandFirstAction(); diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx index d22bfedf..c2e51a79 100644 --- a/frontend/src/settings/ToolConfig.tsx +++ b/frontend/src/settings/ToolConfig.tsx @@ -40,7 +40,7 @@ import ImportSpecModal from '../modals/ImportSpecModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import { getMethodBadgeVariant } from '../utils/httpMethodColors'; -import { can } from '../utils/accessUtils'; +import { can, isOwner } from '../utils/accessUtils'; import { isSharedOAuthMcp } from '../utils/toolUtils'; import { areObjectsEqual } from '../utils/objectUtils'; import { cn, focusRing } from '@/lib/utils'; @@ -125,7 +125,12 @@ export default function ToolConfig({ // A shared OAuth server's connection stays with its owner (the backend // refuses it), so its fields lock like credentials the caller can't change. const sharedOAuth = isSharedOAuthMcp(tool); - const canEditCredentials = can(tool, 'edit_credentials') && !sharedOAuth; + // A connected tool's secret lives on the owner's connection, which the + // server lets only its owner change, whatever the owner's switch says. + const ownerOnlyConnection = + 'connection_id' in tool && !!tool.connection_id && !isOwner(tool); + const canEditCredentials = + can(tool, 'edit_credentials') && !sharedOAuth && !ownerOnlyConnection; // Neither: the tool opens as a read-only view with no Save. const readOnly = !canEdit && !canEditCredentials; const access = React.useMemo( @@ -315,17 +320,6 @@ export default function ToolConfig({ } }; - const handleDelete = () => { - // A draft (a new OpenAPI tool not saved yet) has nothing to delete. - if (!tool.id) { - handleGoBack(); - return; - } - userService.deleteTool({ id: tool.id }, token).then(() => { - handleGoBack(); - }); - }; - const handleAddNewAction = (actionName: string) => { const toolCopy = tool as APIToolType; diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx index 36c08a82..78eb61d9 100644 --- a/frontend/src/settings/Tools.test.tsx +++ b/frontend/src/settings/Tools.test.tsx @@ -69,7 +69,13 @@ vi.mock('../modals/MCPServerModal', () => ({ return null; }, })); -vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null })); +const shareModalProps = vi.fn(); +vi.mock('../teams/ShareToTeamModal', () => ({ + default: (props: unknown) => { + shareModalProps(props); + return null; + }, +})); vi.mock('../api/services/devicesService', () => ({ default: {} })); // The connector panel itself is tested on its own; here it only has to open @@ -489,6 +495,89 @@ describe('Tools page connections', () => { expect(card('My API').querySelector('[role="switch"]')).not.toBeNull(); }); + it('gives a viewer of a connected tool View, not Manage connection', async () => { + getUserTools.mockImplementation(() => + jsonResponse({ + tools: [ + { + ...TOOLS[0], + customName: 'Telegram (shared)', + connection_id: 'owner-conn', + access: 'viewer', + ownership: 'team', + team_access: 'viewer', + allowed_actions: ['use', 'use_in_own'], + }, + ], + }), + ); + await renderTools(root); + expect(menuItem('Telegram (shared)', 'settings.tools.view')).toBeDefined(); + expect( + menuItem('Telegram (shared)', 'settings.connectors.manageConnection'), + ).toBeUndefined(); + expect( + menuItem('Telegram (shared)', 'settings.tools.reconnect'), + ).toBeUndefined(); + }); + + // The connections load after the tools; the owner must not see the raw + // MCP Reconnect for a connected tool in between. + it("offers no Reconnect for the owner's connected MCP tool before connections load", async () => { + reduxState.connectors.connections = []; + getUserTools.mockImplementation(() => + jsonResponse({ + tools: [ + { + ...TOOLS[2], + customName: 'Linear (mine)', + config: { auth_type: 'bearer' }, + access: 'owner', + }, + ], + }), + ); + await renderTools(root); + expect( + menuItem('Linear (mine)', 'settings.tools.reconnect'), + ).toBeUndefined(); + }); + + it("shows an editor who may share the owner's account, locked, with the write confirmation", async () => { + shareModalProps.mockReset(); + getUserTools.mockImplementation(() => + jsonResponse({ + tools: [ + { + ...TOOLS[0], + customName: 'Telegram (shared)', + connection_id: 'owner-conn', + credential_mode: 'owner', + actions: [{ name: 'send', access: 'write' }], + access: 'editor', + ownership: 'team', + team_access: 'editor', + allowed_actions: ['edit', 'share', 'use', 'use_in_own'], + }, + ], + }), + ); + await renderTools(root); + await act(async () => + menuItem('Telegram (shared)', 'settings.tools.shareWithTeam')!.click(), + ); + const props = shareModalProps.mock.calls.at(-1)?.[0] as { + credentials?: Record; + }; + expect(props.credentials).toMatchObject({ + toolId: 'tg', + connectorName: 'Telegram', + mode: 'owner', + hasWrites: true, + readOnly: true, + }); + }); + it("offers no Reconnect for a teammate's connected MCP tool", async () => { getUserTools.mockImplementation(() => jsonResponse({ diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index 75a20115..ae5be48d 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -33,6 +33,7 @@ import { } from '../connectors/connectorsSlice'; import { connectorDescription } from '../connectors/i18n'; import type { Connection, ConnectorDefinition } from '../connectors/types'; +import { toolServiceOf } from '../connectors/toolService'; import useConnectorLauncher from '../connectors/useConnectorLauncher'; import { useLoaderState } from '../hooks'; import type { AvailableToolType } from '../modals/types'; @@ -94,26 +95,29 @@ export default function Tools() { }>({}); // A connection-backed tool shares its connection's account or asks each - // member to connect their own; the share dialog shows that choice. + // member to connect their own; the share dialog shows that choice. Whose + // account it is stays the owner's choice: an editor the owner lets share + // sees it locked, and still confirms before sharing a tool that can act. const shareCredentials = ( tool: UserToolType, ): ShareCredentials | undefined => { - const connection = tool.connection_id - ? connections.find((c) => c.id === tool.connection_id) - : undefined; - if (!connection) return undefined; - const policy = catalog.find( - (c) => c.key === connection.connector_key, - )?.credential_policy; + if (!tool.connection_id) return undefined; + const owner = isOwner(tool); + const service = toolServiceOf(tool, connections, catalog); + // The owner's own connection names the account; until it loads there + // is nothing to show them. + if (!service || (owner && !service.connection)) return undefined; + const policy = service.connector?.credential_policy; return { toolId: tool.id, - connectorName: connection.name, - account: connection.account_label, + connectorName: service.name, + account: owner ? (service.connection?.account_label ?? '') : '', mode: tool.credential_mode === 'member' ? 'member' : 'owner', forcedMode: policy === 'owner' || policy === 'member' ? policy : null, hasWrites: (tool.actions ?? []).some( (action) => action.access === 'write', ), + readOnly: !owner, }; }; @@ -209,13 +213,13 @@ export default function Tools() { }, ]; // A connected server reconnects on its connector page, like any other - // connection; only an MCP tool without one keeps the server form. A - // teammate never sees the owner's connection, so its id alone rules - // them out. A shared OAuth server's sign-in is the owner's to redo. - const hasConnection = isOwner(tool) ? !!connection : !!tool.connection_id; + // connection; only an MCP tool without one keeps the server form. The + // tool's own connection id decides, for everyone: a teammate never sees + // the owner's connection, and the owner's loads after the tools. A + // shared OAuth server's sign-in is the owner's to redo. if ( tool.name === 'mcp_tool' && - !hasConnection && + !tool.connection_id && can(tool, 'edit_credentials') && !isSharedOAuthMcp(tool) ) { @@ -698,10 +702,7 @@ export default function Tools() { resourceType="tool" resourceId={toolToShare.id} resourceName={toolToShare.customName || toolToShare.displayName} - // Whose account shares use is the owner's choice alone. - credentials={ - isOwner(toolToShare) ? shareCredentials(toolToShare) : undefined - } + credentials={shareCredentials(toolToShare)} onClose={() => { setToolToShare(null); getUserTools(); diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx index 7df25c25..765614af 100644 --- a/frontend/src/teams/ShareToTeamModal.test.tsx +++ b/frontend/src/teams/ShareToTeamModal.test.tsx @@ -476,4 +476,101 @@ describe('ShareToTeamModal credentials', () => { expect(toggle('owner').disabled).toBe(true); expect(text()).toContain('settings.connectors.share.forced'); }); + + describe('access settings of a connected tool', () => { + const toolSettings = (values: Record = {}) => ({ + success: true, + resource_type: 'tool', + resource_id: 'tool-1', + settings: [ + { key: 'editors_can_change_credentials', default: true }, + { key: 'editors_can_share', default: false }, + { key: 'viewers_can_use_in_agents', default: true }, + ].map((s) => ({ ...s, value: values[s.key] ?? s.default })), + access: 'owner', + allowed_actions: OWNER_ACTIONS, + }); + const openSettings = () => + act(() => buttonByText('settings.teams.accessSettings.title')!.click()); + + beforeEach(() => { + getResourceSettings.mockResolvedValue(toolSettings()); + }); + + // The connection's secret is the owner's alone, so the switch would + // promise editors something the server refuses. + it('has no switch for editors to change credentials', async () => { + await render(credentials()); + openSettings(); + expect( + body().querySelector('#share-setting-editors_can_change_credentials'), + ).toBeNull(); + expect( + body().querySelector('#share-setting-viewers_can_use_in_agents'), + ).not.toBeNull(); + }); + + it('keeps the switch for a tool with no connection', async () => { + await render(); + openSettings(); + expect( + body().querySelector('#share-setting-editors_can_change_credentials'), + ).not.toBeNull(); + }); + + it('tells editors they cannot change credentials', async () => { + await render(credentials()); + const hint = body().querySelector('[data-testid="share-editor-hint"]'); + expect(hint?.textContent).toContain( + 'settings.teams.editorHint.toolNoCredentials', + ); + }); + + it("says viewers' runs use each member's own account in member mode", async () => { + await render(credentials({ mode: 'member' })); + openSettings(); + expect(text()).toContain( + 'settings.teams.accessSettings.tool.viewers_can_use_in_agents.descriptionMember', + ); + }); + + it("says viewers' runs use the owner's credentials in owner mode", async () => { + await render(credentials()); + openSettings(); + expect(text()).toContain( + 'settings.teams.accessSettings.tool.viewers_can_use_in_agents.description', + ); + expect(text()).not.toContain('descriptionMember'); + }); + }); + + describe('for an editor allowed to share', () => { + const shared = (overrides: Partial = {}) => + credentials({ account: '', readOnly: true, ...overrides }); + + it("shows whose account shares use but doesn't let them change it", async () => { + await render(shared()); + expect(toggle('owner').getAttribute('aria-checked')).toBe('true'); + expect(toggle('owner').disabled).toBe(true); + expect(toggle('member').disabled).toBe(true); + expect(text()).toContain('settings.connectors.share.ownerChooses'); + expect(text()).toContain( + 'settings.connectors.share.ownerWarningShared(name=Linear)', + ); + }); + + it('still asks for the write confirmation before adding people', async () => { + await render(shared({ hasWrites: true })); + expect(text()).toContain( + 'settings.connectors.share.confirmWriteShared(name=Linear)', + ); + expect(picker().disabled).toBe(true); + await act(async () => + body() + .querySelector('#share-confirm-writes')! + .click(), + ); + expect(picker().disabled).toBe(false); + }); + }); }); diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx index 104183c9..f0a1da2e 100644 --- a/frontend/src/teams/ShareToTeamModal.tsx +++ b/frontend/src/teams/ShareToTeamModal.tsx @@ -66,6 +66,7 @@ import { errorMessage, resolveSettings, settingCopy, + shownSettings, } from './accessSettings'; import { loadTeams, selectTeams } from './teamsSlice'; @@ -73,12 +74,18 @@ import { loadTeams, selectTeams } from './teamsSlice'; export type ShareCredentials = { toolId: string; connectorName: string; + /** The connection's account; empty when the caller isn't its owner. */ account: string; mode: 'owner' | 'member'; /** Set when an admin forces one mode for every share of this connector. */ forcedMode?: 'owner' | 'member' | null; /** Owner-mode shares of a tool with write actions need an explicit OK. */ hasWrites: boolean; + /** + * An editor the owner lets share: the mode is the owner's choice, shown + * but locked, and the write confirmation still applies. + */ + readOnly?: boolean; }; type Props = { @@ -157,7 +164,7 @@ export default function ShareToTeamModal({ const needsWriteConfirm = !!credentials && credentialMode === 'owner' && credentials.hasWrites; const changeCredentialMode = (mode: 'owner' | 'member') => { - if (!credentials || mode === credentialMode) return; + if (!credentials || credentials.readOnly || mode === credentialMode) return; const previous = credentialMode; setCredentialMode(mode); connectorsService @@ -261,7 +268,12 @@ export default function ShareToTeamModal({ .getResourceSettings(resourceType, resourceId, token) .then((r) => { setSettingsInfo(r); - if (anyChanged(resolveSettings(resourceType, r?.settings))) { + const shown = shownSettings( + resourceType, + resolveSettings(resourceType, r?.settings), + !!credentials, + ); + if (anyChanged(shown)) { setSettingsOpen(true); } }) @@ -271,9 +283,16 @@ export default function ShareToTeamModal({ }); }, [resourceType, resourceId]); + // A connected tool hides "Editors can change credentials": its secret is + // the owner's connection, which editors never change. const settings = useMemo( - () => resolveSettings(resourceType, settingsInfo?.settings), - [resourceType, settingsInfo], + () => + shownSettings( + resourceType, + resolveSettings(resourceType, settingsInfo?.settings), + !!credentials, + ), + [resourceType, settingsInfo, credentials], ); const canManageSettings = can(settingsInfo, 'manage_settings'); @@ -798,7 +817,12 @@ export default function ShareToTeamModal({ {settingsOpen && ( {settings.map((setting) => { - const copy = settingCopy(t, resourceType, setting.key); + const copy = settingCopy( + t, + resourceType, + setting.key, + credentials ? credentialMode : undefined, + ); const id = `share-setting-${setting.key}`; return ( {mode === 'owner' ? : } @@ -868,21 +893,32 @@ export default function ShareToTeamModal({ ))} - {credentials.forcedMode && ( + {credentials.forcedMode ? (

{t('settings.connectors.share.forced')}

+ ) : ( + credentials.readOnly && ( +

+ {t('settings.connectors.share.ownerChooses')} +

+ ) )} {credentialMode === 'owner' ? ( // A tool that can act asks for the confirmation below, which // says the same; one that only reads gets a plain line. needsWriteConfirm ? null : (

- {t('settings.connectors.share.ownerWarning', { - account: credentials.account, - name: credentials.connectorName, - interpolation: { escapeValue: false }, - })} + {credentials.readOnly + ? t('settings.connectors.share.ownerWarningShared', { + name: credentials.connectorName, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.share.ownerWarning', { + account: credentials.account, + name: credentials.connectorName, + interpolation: { escapeValue: false }, + })}

) ) : ( @@ -906,7 +942,12 @@ export default function ShareToTeamModal({ htmlFor="share-confirm-writes" className="text-sm font-normal" > - {t('settings.connectors.share.confirmWrite')} + {credentials.readOnly + ? t('settings.connectors.share.confirmWriteShared', { + name: credentials.connectorName, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.share.confirmWrite')} )} @@ -1036,7 +1077,7 @@ export default function ShareToTeamModal({ data-testid="share-editor-hint" className="text-muted-foreground mt-1.5 text-xs" > - {editorHint(t, resourceType, settings)} + {editorHint(t, resourceType, settings, !!credentials)}

diff --git a/frontend/src/teams/accessSettings.ts b/frontend/src/teams/accessSettings.ts index 46306127..393113e9 100644 --- a/frontend/src/teams/accessSettings.ts +++ b/frontend/src/teams/accessSettings.ts @@ -62,18 +62,34 @@ export const settingValue = ( key: string, ): boolean => settings.find((s) => s.key === key)?.value ?? false; +/** + * The switches the share dialog lists. A connected tool drops "Editors can + * change credentials": its secret is on the owner's connection, which only + * the owner can change whatever the switch says. + */ +export const shownSettings = ( + type: ResourceType, + settings: ResourceSetting[], + connected = false, +): ResourceSetting[] => + type === 'tool' && connected + ? settings.filter((s) => s.key !== 'editors_can_change_credentials') + : settings; + /** Whether any switch is off its default (opens Access settings by default). */ export const anyChanged = (settings: ResourceSetting[]): boolean => settings.some((s) => s.value !== s.default); /** * What an Editor can do on this resource, as one or two sentences: a base - * sentence per type, then whether they may also share or delete it. + * sentence per type, then whether they may also share or delete it. Editors + * never change a connected tool's credentials (see `shownSettings`). */ export function editorHint( t: TFunction, type: ResourceType, settings: ResourceSetting[], + connected = false, ): string { const on = (key: string) => settingValue(settings, key); let base: string; @@ -82,9 +98,10 @@ export function editorHint( ? t('settings.teams.editorHint.agent') : t('settings.teams.editorHint.agentNoAccessDetails'); } else if (type === 'tool') { - base = on('editors_can_change_credentials') - ? t('settings.teams.editorHint.tool') - : t('settings.teams.editorHint.toolNoCredentials'); + base = + !connected && on('editors_can_change_credentials') + ? t('settings.teams.editorHint.tool') + : t('settings.teams.editorHint.toolNoCredentials'); } else { base = t(`settings.teams.editorHint.${type}`); } @@ -111,19 +128,33 @@ export function editorHint( return `${base} ${tail}`; } -/** A switch's label and description in the share dialog. */ +/** + * A switch's label and description in the share dialog. `credentialMode` is + * a connected tool's: in member mode a viewer's agent runs with the viewer's + * own account, not the owner's credentials. + */ export const settingCopy = ( t: TFunction, type: ResourceType, key: string, -): { label: string; description: string } => ({ - label: t(`settings.teams.accessSettings.${type}.${key}.label`, { - defaultValue: key, - }), - description: t(`settings.teams.accessSettings.${type}.${key}.description`, { - defaultValue: '', - }), -}); + credentialMode?: 'owner' | 'member', +): { label: string; description: string } => { + const description = + type === 'tool' && + key === 'viewers_can_use_in_agents' && + credentialMode === 'member' + ? 'descriptionMember' + : 'description'; + return { + label: t(`settings.teams.accessSettings.${type}.${key}.label`, { + defaultValue: key, + }), + description: t( + `settings.teams.accessSettings.${type}.${key}.${description}`, + { defaultValue: '' }, + ), + }; +}; /** * The read-only "What people here can do" lines: the viewer and editor