mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 20:14:16 +00:00
Keep a connected tool's credentials with its owner in the UI
The server lets only the owner change the secret on a tool's connection, so an editor no longer gets live credential fields on a connected tool: the locked-credentials notice shows and no config is sent, so a rename or action edit still saves. The share dialog drops the "Editors can change credentials" switch for a connected tool, uses the matching editor hint, and says a viewer's runs use each member's own account in member mode. An editor the owner lets share now sees whose account shares use, locked, and still has to confirm before sharing a tool that can act. The owner's Reconnect is decided by the tool's connection id, so it no longer flashes before the connections load, and the connection panel's tool switch is labelled "In my chats", which is what it sets.
This commit is contained in:
1 parent
4f5cd68771
commit
0eb309a592
19 files changed
+660
-91
No files matched your search
+2
-1
@@ -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: `<fieldset disabled className="min-w-0">`);
|
||||
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
|
||||
|
||||
@@ -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<HTMLButtonElement>(
|
||||
'[aria-label="settings.tools.useInMyChatsAria"]',
|
||||
)!;
|
||||
expect(toggle.getAttribute('role')).toBe('switch');
|
||||
const label = document.body.querySelector<HTMLLabelElement>(
|
||||
`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<HTMLButtonElement>(
|
||||
'[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<HTMLButtonElement>(
|
||||
'[aria-label="settings.connectors.detail.toolSwitch"]',
|
||||
'[aria-label="settings.tools.useInMyChatsAria"]',
|
||||
)!;
|
||||
await act(async () => toggle.click());
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true');
|
||||
|
||||
@@ -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 (
|
||||
<Switch
|
||||
checked={on}
|
||||
aria-label={t('settings.connectors.detail.toolSwitch', {
|
||||
name: tool.display_name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
onCheckedChange={async (checked) => {
|
||||
const next = checked === true;
|
||||
setOn(next);
|
||||
if (!(await onToggle(tool.id, next))) setOn(!next);
|
||||
}}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<Label htmlFor={id} className="text-muted-foreground text-xs font-normal">
|
||||
{t('settings.tools.inMyChats')}
|
||||
</Label>
|
||||
<Switch
|
||||
id={id}
|
||||
checked={on}
|
||||
aria-label={t('settings.tools.useInMyChatsAria', {
|
||||
toolName: tool.display_name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
onCheckedChange={async (checked) => {
|
||||
const next = checked === true;
|
||||
setOn(next);
|
||||
if (!(await onToggle(tool.id, next))) setOn(!next);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { toolServiceOf } from './toolService';
|
||||
import type { Connection, ConnectorDefinition } from './types';
|
||||
|
||||
const connection = (overrides: Partial<Connection>): 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>,
|
||||
): 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 });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "コネクタを追加できませんでした。もう一度お試しください。",
|
||||
|
||||
@@ -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": "Не удалось добавить коннектор. Попробуйте ещё раз.",
|
||||
|
||||
@@ -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": "無法新增連接器。請再試一次。",
|
||||
|
||||
@@ -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": "无法添加连接器。请重试。",
|
||||
|
||||
@@ -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<HTMLInputElement>(
|
||||
'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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, boolean> = {}) => ({
|
||||
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<ShareCredentials> = {}) =>
|
||||
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<HTMLButtonElement>('#share-confirm-writes')!
|
||||
.click(),
|
||||
);
|
||||
expect(picker().disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<ResourceSetting[]>(
|
||||
() => 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 && (
|
||||
<SettingRows>
|
||||
{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 (
|
||||
<SettingRow
|
||||
@@ -858,8 +882,9 @@ export default function ShareToTeamModal({
|
||||
key={mode}
|
||||
value={mode}
|
||||
disabled={
|
||||
!!credentials.forcedMode &&
|
||||
credentials.forcedMode !== mode
|
||||
!!credentials.readOnly ||
|
||||
(!!credentials.forcedMode &&
|
||||
credentials.forcedMode !== mode)
|
||||
}
|
||||
>
|
||||
{mode === 'owner' ? <UserRound /> : <UsersRound />}
|
||||
@@ -868,21 +893,32 @@ export default function ShareToTeamModal({
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
{credentials.forcedMode && (
|
||||
{credentials.forcedMode ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.connectors.share.forced')}
|
||||
</p>
|
||||
) : (
|
||||
credentials.readOnly && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.connectors.share.ownerChooses')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{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 : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{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 },
|
||||
})}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
@@ -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')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
@@ -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)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user