diff --git a/docs/content/Guides/Connectors.mdx b/docs/content/Guides/Connectors.mdx index e9a27957..6cb651e4 100644 --- a/docs/content/Guides/Connectors.mdx +++ b/docs/content/Guides/Connectors.mdx @@ -50,11 +50,11 @@ Tool calls name the service that ran them: "Searched Notion", "Read from Google Admins manage connectors in **Admin > Connectors**. It lists every connector with its status, how many connections use it, and: - **Enabled** turns a connector off for everyone. Members no longer see it (except to manage a connection they already have), its tools stop working, and its sources stop syncing until you turn it back on. Existing connections are kept. -- **Shared tools use** is **Let owners choose** (default), **Always the owner's account** or **Always each member's own account**. -- **Allow custom MCP servers** controls whether members can add their own MCP servers. Presets are enabled one by one. +- **Shared tools use** is **The sharer decides per share** (default), **Always the sharer's account** or **Always each person's own account**. Connectors that only sync content have no tools and show **No tools**. +- The **MCP server** row decides whether members can add their own MCP servers. Presets are switched one by one. - **Setup guide** on an OAuth connector shows the redirect URI to register and which server settings are still missing. -A connector that still needs server settings starts turned off and is hidden from members. It turns on once its settings are present, unless you switched it off; switching it on before then has no effect for members. +A connector that still needs server settings starts turned off and is hidden from members; its switch stays disabled until the settings are present. It turns on once they are, unless you switched it off. On a phone, the page lists the connectors and opens each one's controls in a panel. ### Encryption key diff --git a/docsgpt/api/admin/connectors.py b/docsgpt/api/admin/connectors.py index e727672d..5b17da40 100644 --- a/docsgpt/api/admin/connectors.py +++ b/docsgpt/api/admin/connectors.py @@ -70,6 +70,7 @@ class AdminConnectorsResource(Resource): "category": definition.category, "publisher": definition.publisher, "auth_kind": definition.auth_kind, + "capabilities": list(definition.capabilities), "enabled": service.connector_is_enabled(policies, definition.key), "credential_mode": policy.get("credential_mode", "choose"), "configured": definition.configured, diff --git a/frontend/src/admin/Connectors.test.tsx b/frontend/src/admin/Connectors.test.tsx index 7f5d56c3..95a67a23 100644 --- a/frontend/src/admin/Connectors.test.tsx +++ b/frontend/src/admin/Connectors.test.tsx @@ -30,6 +30,7 @@ const connector = (overrides: Record = {}) => ({ icon: 'google-drive', publisher: 'built_in', auth_kind: 'oauth', + capabilities: ['sync'], enabled: true, credential_mode: 'choose', configured: false, @@ -43,6 +44,29 @@ const connector = (overrides: Record = {}) => ({ ...overrides, }); +const MCP_ROW = connector({ + key: 'custom_mcp', + name: 'MCP server', + icon: 'mcp', + publisher: 'custom', + auth_kind: 'mcp', + capabilities: ['read', 'write'], + configured: true, + required_settings: [], + connection_count: 0, +}); +const NOTION = { + key: 'mcp_notion', + name: 'Notion', + icon: 'notion', + publisher: 'preset', + auth_kind: 'mcp_oauth', + capabilities: ['read', 'write'], + configured: true, + required_settings: [], + connection_count: 0, +}; + const payload = (overrides: Record = {}) => ({ success: true, connectors: [ @@ -53,10 +77,12 @@ const payload = (overrides: Record = {}) => ({ icon: 'notion', publisher: 'preset', auth_kind: 'mcp_oauth', + capabilities: ['read', 'write'], configured: true, required_settings: [], connection_count: 0, }), + MCP_ROW, ], allow_custom_mcp: true, default_encryption_key: false, @@ -106,7 +132,7 @@ describe('Admin Connectors', () => { getAdmin.mockResolvedValue(payload()); await render(); const rows = Array.from(container.querySelectorAll('tbody tr')); - expect(rows).toHaveLength(2); + expect(rows).toHaveLength(3); expect(rows[0].textContent).toContain('Google Drive'); expect(rows[0].textContent).toContain('Needs setup'); expect(rows[0].textContent).toContain('Setup guide'); @@ -130,24 +156,74 @@ describe('Admin Connectors', () => { it('saves a connector toggle as a policy', async () => { getAdmin.mockResolvedValue(payload()); updateAdmin.mockResolvedValue( - payload({ - connectors: [connector({ enabled: false })], - }), + payload({ connectors: [connector({ ...NOTION, enabled: false })] }), ); await render(); - const toggle = container.querySelector( - '[aria-label="Google Drive enabled"]', - )!; - await act(async () => toggle.click()); + const toggle = () => + container.querySelector( + 'table [aria-label="Notion enabled"]', + )!; + await act(async () => toggle().click()); expect(updateAdmin).toHaveBeenCalledWith( - { policies: { google_drive: { enabled: false } } }, + { policies: { mcp_notion: { enabled: false } } }, null, ); + expect(toggle().getAttribute('aria-checked')).toBe('false'); + }); + + it('keeps a connector that needs setup off, and says why', async () => { + getAdmin.mockResolvedValue(payload()); + await render(); + const drive = container.querySelector( + 'table [aria-label="Google Drive enabled"]', + )!; + expect(drive.disabled).toBe(true); + }); + + it('shows no sharing policy for a sync-only connector', async () => { + getAdmin.mockResolvedValue(payload()); + await render(); + const [drive, notion] = Array.from(container.querySelectorAll('tbody tr')); + expect(drive.textContent).toContain('No tools'); expect( - container - .querySelector('[aria-label="Google Drive enabled"]') - ?.getAttribute('aria-checked'), - ).toBe('false'); + notion.querySelector('[aria-label="Notion sharing policy"]'), + ).not.toBeNull(); + }); + + it('turns custom MCP servers off from their own row', async () => { + getAdmin.mockResolvedValue(payload()); + updateAdmin.mockResolvedValue(payload({ allow_custom_mcp: false })); + await render(); + const mcp = () => + container.querySelector( + 'table [aria-label="MCP server enabled"]', + )!; + await act(async () => mcp().click()); + expect(updateAdmin).toHaveBeenCalledWith( + { allow_custom_mcp: false, policies: { custom_mcp: { enabled: false } } }, + null, + ); + expect(mcp().getAttribute('aria-checked')).toBe('false'); + expect(container.querySelector('#allow-custom-mcp')).toBeNull(); + }); + + it('lists connectors on phones and opens their controls in a sheet', async () => { + getAdmin.mockResolvedValue(payload()); + await render(); + const rows = Array.from( + container.querySelectorAll( + '[data-slot="list-row"] button', + ), + ); + expect(rows).toHaveLength(3); + expect(rows[0].textContent).toContain( + 'Needs setup · 3 connections · No tools', + ); + await act(async () => rows[1].click()); + expect( + document.body.querySelector('[data-slot="sheet-content"]'), + ).not.toBeNull(); + expect(document.body.textContent).toContain('Shared tools use'); }); it('reports a failed save in a toast and keeps the page', async () => { @@ -155,14 +231,21 @@ describe('Admin Connectors', () => { updateAdmin.mockResolvedValue({ success: false }); await render(); await act(async () => - container.querySelector('#allow-custom-mcp')!.click(), + container + .querySelector( + 'table [aria-label="Notion enabled"]', + )! + .click(), + ); + expect(updateAdmin).toHaveBeenCalledWith( + { policies: { mcp_notion: { enabled: false } } }, + null, ); - expect(updateAdmin).toHaveBeenCalledWith({ allow_custom_mcp: false }, null); expect(selectActionToast(store.getState())).toMatchObject({ variant: 'destructive', message: 'Could not save the change.', }); - expect(container.querySelectorAll('tbody tr')).toHaveLength(2); + expect(container.querySelectorAll('tbody tr')).toHaveLength(3); }); it('saves one change at a time so a late response cannot win', async () => { @@ -172,34 +255,28 @@ describe('Admin Connectors', () => { () => new Promise((resolve) => pending.push(resolve)), ); await render(); - const toggle = () => + const notion = () => container.querySelector( - '[aria-label="Google Drive enabled"]', + 'table [aria-label="Notion enabled"]', )!; - await act(async () => toggle().click()); - await act(async () => - container.querySelector('#allow-custom-mcp')!.click(), - ); + const mcp = () => + container.querySelector( + 'table [aria-label="MCP server enabled"]', + )!; + await act(async () => notion().click()); + await act(async () => mcp().click()); // The second save waits for the first. expect(updateAdmin).toHaveBeenCalledTimes(1); - await act(async () => - pending[0](payload({ connectors: [connector({ enabled: false })] })), - ); + const off = connector({ ...NOTION, enabled: false }); + await act(async () => pending[0](payload({ connectors: [off, MCP_ROW] }))); expect(updateAdmin).toHaveBeenCalledTimes(2); await act(async () => pending[1]( - payload({ - connectors: [connector({ enabled: false })], - allow_custom_mcp: false, - }), + payload({ connectors: [off, MCP_ROW], allow_custom_mcp: false }), ), ); - expect(toggle().getAttribute('aria-checked')).toBe('false'); - expect( - container - .querySelector('#allow-custom-mcp')! - .getAttribute('aria-checked'), - ).toBe('false'); + expect(notion().getAttribute('aria-checked')).toBe('false'); + expect(mcp().getAttribute('aria-checked')).toBe('false'); }); it('offers a retry when loading fails', async () => { diff --git a/frontend/src/admin/Connectors.tsx b/frontend/src/admin/Connectors.tsx index bffb55a9..e78fbbe1 100644 --- a/frontend/src/admin/Connectors.tsx +++ b/frontend/src/admin/Connectors.tsx @@ -9,6 +9,14 @@ import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'; import { Badge } from '../components/ui/badge'; import { Button } from '../components/ui/button'; import { Card } from '../components/ui/card'; +import { FormField } from '../components/ui/form-field'; +import { ListRow, ListRows } from '../components/ui/list-row'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetTitle, +} from '../components/ui/sheet'; import { DescriptionItem, DescriptionList, @@ -34,6 +42,11 @@ import { TableHeader, TableRow, } from '../components/ui/table'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '../components/ui/tooltip'; import ConnectorIcon from '../connectors/ConnectorIcon'; import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; @@ -47,6 +60,7 @@ type AdminConnector = { icon: string; publisher: 'built_in' | 'preset' | 'custom'; auth_kind: string; + capabilities: string[]; enabled: boolean; credential_mode: Policy; configured: boolean; @@ -66,11 +80,14 @@ type AdminConnectorsData = { }; const POLICY_LABELS: Record = { - choose: 'Let owners choose', - member: "Always each member's own account", - owner: "Always the owner's account", + choose: 'The sharer decides per share', + member: "Always each person's own account", + owner: "Always the sharer's account", }; +const hasTools = (connector: AdminConnector) => + connector.capabilities.some((capability) => capability !== 'sync'); + function CodeRow({ value }: { value: string }) { return ( @@ -161,6 +178,7 @@ export default function Connectors() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [guide, setGuide] = useState(null); + const [detailKey, setDetailKey] = useState(null); const load = useCallback(async () => { setLoading(true); @@ -207,6 +225,94 @@ export default function Connectors() { if (!data?.success) return ; + // The sheet (phones) always shows the connector's latest saved state. + const detail = data.connectors.find((c) => c.key === detailKey) ?? null; + + // The custom MCP row is the one switch for members' own MCP servers. + const isCustomMcp = (connector: AdminConnector) => + connector.key === 'custom_mcp'; + const enabledOf = (connector: AdminConnector) => + connector.enabled && (!isCustomMcp(connector) || data.allow_custom_mcp); + const setEnabled = (connector: AdminConnector, on: boolean) => + save( + isCustomMcp(connector) + ? { allow_custom_mcp: on, policies: { custom_mcp: { enabled: on } } } + : { policies: { [connector.key]: { enabled: on } } }, + ); + + const statusBadge = (connector: AdminConnector) => ( + + {connector.configured ? 'Ready' : 'Needs setup'} + + ); + + const summary = (connector: AdminConnector) => + [ + !connector.configured + ? 'Needs setup' + : enabledOf(connector) + ? 'On' + : 'Off', + `${fmtNumber(connector.connection_count)} ${ + connector.connection_count === 1 ? 'connection' : 'connections' + }`, + hasTools(connector) + ? POLICY_LABELS[connector.credential_mode] + : 'No tools', + ].join(' · '); + + const enabledSwitch = (connector: AdminConnector) => { + const control = ( + setEnabled(connector, checked === true)} + /> + ); + // Off until its server settings exist: switching it on would do nothing. + if (connector.configured) return control; + return ( + + + + {control} + + + Add its server settings first + + ); + }; + + const policyControl = (connector: AdminConnector, fullWidth = false) => + hasTools(connector) ? ( + + ) : ( + No tools + ); + return (
@@ -229,22 +335,6 @@ export default function Connectors() { )} - - - - save({ allow_custom_mcp: checked === true }) - } - /> - - -
- - + + {/* Phones: a list; each row opens the connector's controls. */} + + + {data.connectors.map((connector) => ( + + } + title={connector.name} + description={summary(connector)} + trailing={statusBadge(connector)} + > +