diff --git a/docs/content/Guides/Connectors.mdx b/docs/content/Guides/Connectors.mdx index 26e6ffb9..a8c24482 100644 --- a/docs/content/Guides/Connectors.mdx +++ b/docs/content/Guides/Connectors.mdx @@ -45,12 +45,12 @@ 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 see it as turned off and cannot connect it, its tools stop working, and its sources stop syncing until you turn it back on. Existing connections are kept. +- **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. - **Setup guide** on an OAuth connector shows the redirect URI to register and which server settings are still missing. -A connector that needs server settings stays visible to members as **Needs admin setup** until the settings are present. +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. ### Encryption key diff --git a/docs/content/Guides/Integrations/google-drive-connector.mdx b/docs/content/Guides/Integrations/google-drive-connector.mdx index 53af5056..1414f084 100644 --- a/docs/content/Guides/Integrations/google-drive-connector.mdx +++ b/docs/content/Guides/Integrations/google-drive-connector.mdx @@ -68,7 +68,7 @@ Restart your application, then go to **Settings > Connectors** and pick **Google ## Troubleshooting -- **Shows Needs admin setup** — `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is missing from the backend `.env`. Admin > Connectors lists which one. +- **Google Drive is not offered** — `GOOGLE_CLIENT_ID` or `GOOGLE_CLIENT_SECRET` is missing from the backend `.env`, so the connector stays off. Admin > Connectors lists which one. - **Authentication failed** — Check that the redirect URI matches exactly and equals `CONNECTOR_REDIRECT_BASE_URI`, with no query parameters. Ensure the Google Drive API is enabled. - **Sign-in popup closes but the account never connects** — The frontend origin is not allowed to receive the result. Add it to `CONNECTOR_ALLOWED_ORIGINS` in the backend `.env`. - **Permission denied** — Verify the OAuth consent screen is configured and the user has access to the target files. diff --git a/docsgpt/alembic/versions/0038_connections.py b/docsgpt/alembic/versions/0038_connections.py index e2205929..587a982d 100644 --- a/docsgpt/alembic/versions/0038_connections.py +++ b/docsgpt/alembic/versions/0038_connections.py @@ -43,7 +43,9 @@ Backfill (idempotent, only fills NULLs or unconverted rows): doing so (``member``); owners can switch them in the share dialog. ``connector_policies`` holds the admin's per-connector switches (enabled, -forced credential mode). The instance-wide "Allow custom MCP servers" switch +forced credential mode). ``enabled`` NULL means the default: on when the +connector has the server settings it needs, off (and hidden from members) +until then. The instance-wide "Allow custom MCP servers" switch is the ``connectors.allow_custom_mcp`` key in ``app_metadata`` (absent means allowed). @@ -86,7 +88,7 @@ def upgrade() -> None: """ CREATE TABLE IF NOT EXISTS connector_policies ( connector_key TEXT PRIMARY KEY, - enabled BOOLEAN NOT NULL DEFAULT true, + enabled BOOLEAN, credential_mode TEXT NOT NULL DEFAULT 'choose' CONSTRAINT connector_policies_credential_mode_chk CHECK (credential_mode IN ('choose', 'owner', 'member')), diff --git a/docsgpt/api/admin/connectors.py b/docsgpt/api/admin/connectors.py index 5b2689f0..e727672d 100644 --- a/docsgpt/api/admin/connectors.py +++ b/docsgpt/api/admin/connectors.py @@ -70,7 +70,7 @@ class AdminConnectorsResource(Resource): "category": definition.category, "publisher": definition.publisher, "auth_kind": definition.auth_kind, - "enabled": policy.get("enabled", True), + "enabled": service.connector_is_enabled(policies, definition.key), "credential_mode": policy.get("credential_mode", "choose"), "configured": definition.configured, "required_settings": [ diff --git a/docsgpt/api/user/tools/routes.py b/docsgpt/api/user/tools/routes.py index 61daf14b..1c26d7bf 100644 --- a/docsgpt/api/user/tools/routes.py +++ b/docsgpt/api/user/tools/routes.py @@ -198,6 +198,16 @@ class AvailableTools(Resource): def get(self): if not request.decoded_token: return make_response(jsonify({"success": False}), 401) + from docsgpt.connectors import service as connection_service + + try: + with db_readonly() as conn: + policies = connection_service.load_policies(conn) + except Exception: + # Without the admin's switches, fall back to the defaults (a + # connector is on when its server settings are present). + current_app.logger.warning("Could not read connector policies", exc_info=True) + policies = {} try: tools_metadata = [] for tool_name, tool_instance in tool_manager.tools.items(): @@ -209,6 +219,10 @@ class AvailableTools(Resource): actions = tool_instance.get_actions_metadata() definition = definition_for_tool(tool_name) if definition is not None: + if not (definition.configured and connection_service.connector_is_enabled( + policies, definition.key, + )): + continue group, connector_key = "service", definition.key elif tool_name in _CUSTOM_CONNECTOR_TOOLS: group, connector_key = "custom", _CUSTOM_CONNECTOR_TOOLS[tool_name] diff --git a/docsgpt/connectors/resolve.py b/docsgpt/connectors/resolve.py index 490d7c7f..2c90917a 100644 --- a/docsgpt/connectors/resolve.py +++ b/docsgpt/connectors/resolve.py @@ -77,7 +77,8 @@ def resolve_connection(resource: dict, invoker_user_id: Optional[str]) -> Option repo = ConnectorSessionsRepository(conn) owned = repo.get(str(connection_id)) owned_key = catalog.connector_key_for_row(owned) if owned else None - policy = service.load_policies(conn).get(owned_key) or {} if owned_key else {} + policies = service.load_policies(conn) + policy = (policies.get(owned_key) or {}) if owned_key else {} if policy.get("credential_mode") in (MODE_OWNER, MODE_MEMBER): # An admin forces whose account every share of this connector uses. mode = policy["credential_mode"] @@ -94,7 +95,7 @@ def resolve_connection(resource: dict, invoker_user_id: Optional[str]) -> Option available = ( row is not None and service.normalize_status(row) == service.STATUS_CONNECTED - and policy.get("enabled") is not False + and service.connector_is_enabled(policies, key) ) return ResolvedConnection( row=row, diff --git a/docsgpt/connectors/service.py b/docsgpt/connectors/service.py index 4054e432..7d223b63 100644 --- a/docsgpt/connectors/service.py +++ b/docsgpt/connectors/service.py @@ -223,10 +223,14 @@ def catalog_for_user(conn, user_id: str, *, is_admin: bool, policies: Optional[d entries = [] for definition in catalog.all_definitions(): policy = policies.get(definition.key) or {} - disabled = policy.get("enabled") is False + disabled = not connector_is_enabled(policies, definition.key) missing = definition.missing_settings available = not missing and not disabled statuses = by_key.get(definition.key, []) + if not available and not statuses: + # Members only see what they can use, plus what they connected + # before an admin turned it off (to manage or remove it). + continue status = worst_status(statuses) entries.append( { @@ -1231,6 +1235,24 @@ def custom_mcp_allowed(conn) -> bool: return AppMetadataRepository(conn).get(ALLOW_CUSTOM_MCP_KEY) != "false" +def connector_is_enabled(policies: dict, connector_key: Optional[str]) -> bool: + """Whether a connector is switched on. + + An admin's explicit switch wins. Without one, a connector is on when it + has the server settings it needs, so one that still needs admin setup + starts off and members never see it. + + Args: + policies: ``connector_key`` to policy row, from :func:`load_policies`. + connector_key: The catalog key. + """ + explicit = (policies.get(connector_key) or {}).get("enabled") if connector_key else None + if explicit is not None: + return bool(explicit) + definition = catalog.get_definition(connector_key) if connector_key else None + return definition is None or definition.configured + + def load_policies(conn) -> dict[str, dict]: """Every connector's policy, with custom MCP folded in from its switch.""" from docsgpt.storage.db.repositories.connector_policies import ConnectorPoliciesRepository @@ -1251,8 +1273,7 @@ def connector_enabled(conn, row: dict) -> bool: Returns: False when the connector (or custom MCP servers) is turned off. """ - key = catalog.connector_key_for_row(row) - return (load_policies(conn).get(key) or {}).get("enabled") is not False + return connector_is_enabled(load_policies(conn), catalog.connector_key_for_row(row)) def ensure_connector_allowed(conn, connector_key: Optional[str]) -> None: @@ -1263,8 +1284,7 @@ def ensure_connector_allowed(conn, connector_key: Optional[str]) -> None: """ if not connector_key: return - policy = load_policies(conn).get(connector_key) or {} - if policy.get("enabled") is False: + if not connector_is_enabled(load_policies(conn), connector_key): raise ConnectorDisabled(f"{connector_key} is turned off by an admin") diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py index 59568d22..ff682541 100644 --- a/docsgpt/storage/db/models.py +++ b/docsgpt/storage/db/models.py @@ -419,7 +419,8 @@ user_custom_models_table = Table( # docsgpt.security.encryption.encrypt_credentials. Column("api_key_encrypted", Text, nullable=False), Column("capabilities", JSONB, nullable=False, server_default="{}"), - Column("enabled", Boolean, nullable=False, server_default="true"), + # NULL: on when the connector has its server settings (see connectors.service). + Column("enabled", Boolean), Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), ) diff --git a/docsgpt/storage/db/repositories/connector_policies.py b/docsgpt/storage/db/repositories/connector_policies.py index 2373b8cd..f557e95e 100644 --- a/docsgpt/storage/db/repositories/connector_policies.py +++ b/docsgpt/storage/db/repositories/connector_policies.py @@ -37,14 +37,18 @@ class ConnectorPoliciesRepository: credential_mode: Optional[str] = None, updated_by: Optional[str] = None, ) -> dict: - """Set one connector's policy; fields left None keep their value.""" + """Set one connector's policy; fields left None keep their value. + + A new row leaves ``enabled`` NULL unless it is given, so changing only + the credential mode never switches a connector on. + """ if credential_mode is not None and credential_mode not in CREDENTIAL_POLICIES: raise ValueError(f"unknown credential mode: {credential_mode!r}") row = self._conn.execute( text( """ INSERT INTO connector_policies (connector_key, enabled, credential_mode, updated_by) - VALUES (:key, COALESCE(:enabled, true), COALESCE(:mode, 'choose'), :by) + VALUES (:key, :enabled, COALESCE(:mode, 'choose'), :by) ON CONFLICT (connector_key) DO UPDATE SET enabled = COALESCE(:enabled, connector_policies.enabled), credential_mode = COALESCE(:mode, connector_policies.credential_mode), diff --git a/frontend/src/admin/Connectors.tsx b/frontend/src/admin/Connectors.tsx index b00d4d14..5a1d989e 100644 --- a/frontend/src/admin/Connectors.tsx +++ b/frontend/src/admin/Connectors.tsx @@ -196,8 +196,9 @@ export default function Connectors() {
Choose which connectors members can use and whose account a shared tool - runs with. Connectors that need server settings stay listed for members - as Needs admin setup until you add them. + runs with. A connector that still needs server settings starts turned + off and is hidden from members; it turns on once its settings are in + place, unless you switch it off.
{data.default_encryption_key && ( diff --git a/frontend/src/upload/Upload.test.tsx b/frontend/src/upload/Upload.test.tsx index 1373ef84..d5587db2 100644 --- a/frontend/src/upload/Upload.test.tsx +++ b/frontend/src/upload/Upload.test.tsx @@ -106,6 +106,9 @@ describe('Upload source-type tiles', () => { }); it('groups upload and web apart from connections', async () => { + connectorsState.catalog = [ + { key: 's3', icon: 's3', sync_ingestor: 's3', available: true }, + ]; await render(); expect(document.body.textContent).toContain( 'modals.uploadDoc.groupUploadWeb', @@ -113,33 +116,51 @@ describe('Upload source-type tiles', () => { expect(document.body.textContent).toContain( 'modals.uploadDoc.groupConnection', ); + connectorsState.catalog = []; }); - it('shows connector tiles even when the server is not set up', async () => { + it('hides connector tiles members cannot use', async () => { connectorsState.catalog = [ { key: 'google_drive', icon: 'drive', sync_ingestor: 'google_drive', available: false, - missing_settings: ['GOOGLE_CLIENT_ID'], + missing_settings: [], + }, + { + key: 's3', + icon: 's3', + sync_ingestor: 's3', + auth_kind: 'api_key', + available: true, + missing_settings: [], }, ]; await render(); - const drive = tiles().find((tile) => - tile.textContent?.includes( - 'modals.uploadDoc.ingestors.google_drive.label', - ), + const labels = tiles().map((tile) => tile.textContent ?? ''); + expect(labels.some((l) => l.includes('ingestors.google_drive.label'))).toBe( + false, ); - expect(drive).toBeDefined(); - expect(drive!.textContent).toContain( + // Not in the catalog at all (needs setup, or turned off): not offered. + expect(labels.some((l) => l.includes('ingestors.confluence.label'))).toBe( + false, + ); + expect(labels.some((l) => l.includes('ingestors.s3.label'))).toBe(true); + expect(document.body.textContent).not.toContain( 'settings.connectors.status.needsAdminSetup', ); - await act(async () => drive!.click()); - expect(document.body.textContent).toContain('GOOGLE_CLIENT_ID'); connectorsState.catalog = []; }); + it('hides the connection group when no connector is usable', async () => { + connectorsState.catalog = []; + await render(); + expect(document.body.textContent).not.toContain( + 'modals.uploadDoc.groupConnection', + ); + }); + it('names the connected account on a connection tile', async () => { connectorsState.catalog = [ { diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 22c5e4e5..b6df0624 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -34,6 +34,7 @@ import { loadConnectors, selectConnections, selectConnectorCatalog, + selectConnectorsEnabled, selectConnectorsLoaded, } from '../connectors/connectorsSlice'; import type { AppDispatch } from '../store'; @@ -106,6 +107,7 @@ function Upload({ const connectorCatalog = useSelector(selectConnectorCatalog); const connections = useSelector(selectConnections); const connectorsLoaded = useSelector(selectConnectorsLoaded); + const connectorsEnabled = useSelector(selectConnectorsEnabled); const [files, setfiles] = useState