From 5ebe708bd8d0bca213ded38424a2a0536215a4b8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 17:58:46 +0200 Subject: [PATCH 01/24] BE-1: Alembic migration to drop unique constraint for multiple connectors of same type per search space (idempotent) --- .../57_allow_multiple_connectors_per_type.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py diff --git a/surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py b/surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py new file mode 100644 index 000000000..bd2fccf72 --- /dev/null +++ b/surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py @@ -0,0 +1,53 @@ +"""Allow multiple connectors of same type per search space + +Revision ID: 57 +Revises: 56 +Create Date: 2026-01-06 12:00:00.000000 + +""" + +from collections.abc import Sequence +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "57" +down_revision: str | None = "56" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +from sqlalchemy import text + +def upgrade() -> None: + connection = op.get_bind() + constraint_exists = connection.execute( + text(""" + SELECT 1 FROM information_schema.table_constraints + WHERE table_name='search_source_connectors' + AND constraint_type='UNIQUE' + AND constraint_name='uq_searchspace_user_connector_type' + """) + ).scalar() + if constraint_exists: + op.drop_constraint( + "uq_searchspace_user_connector_type", + "search_source_connectors", + type_="unique" + ) + +def downgrade() -> None: + connection = op.get_bind() + constraint_exists = connection.execute( + text(""" + SELECT 1 FROM information_schema.table_constraints + WHERE table_name='search_source_connectors' + AND constraint_type='UNIQUE' + AND constraint_name='uq_searchspace_user_connector_type' + """) + ).scalar() + if not constraint_exists: + op.create_unique_constraint( + "uq_searchspace_user_connector_type", + "search_source_connectors", + ["search_space_id", "user_id", "connector_type"] + ) + From 9f75a3f0b383f1e83d4dae7b5e2ea5e9faf9c526 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 18:32:18 +0200 Subject: [PATCH 02/24] BE-1: Add connector_naming.py utilities for friendly auto-naming and unique identifier extraction --- .../app/utils/connector_naming.py | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 surfsense_backend/app/utils/connector_naming.py diff --git a/surfsense_backend/app/utils/connector_naming.py b/surfsense_backend/app/utils/connector_naming.py new file mode 100644 index 000000000..5081687ac --- /dev/null +++ b/surfsense_backend/app/utils/connector_naming.py @@ -0,0 +1,60 @@ +from app.db import SearchSourceConnectorType + +# Friendly display names for connector types +BASE_NAME_FOR_TYPE = { + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: "Gmail", + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive", + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", + SearchSourceConnectorType.SLACK_CONNECTOR: "Slack", + SearchSourceConnectorType.NOTION_CONNECTOR: "Notion", + SearchSourceConnectorType.GITHUB_CONNECTOR: "GitHub", + SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear", + SearchSourceConnectorType.JIRA_CONNECTOR: "Jira", + SearchSourceConnectorType.DISCORD_CONNECTOR: "Discord", + SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence", + SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable", + SearchSourceConnectorType.LUMA_CONNECTOR: "Luma", + # Add other connectors as needed, fallback below +} + +def get_base_name_for_type(connector_type: SearchSourceConnectorType) -> str: + return BASE_NAME_FOR_TYPE.get(connector_type, connector_type.replace("_", " ").title()) + + +def generate_unique_connector_name(connector_type: SearchSourceConnectorType, identifier: str | None) -> str: + base = get_base_name_for_type(connector_type) + if identifier: + return f"{base} - {identifier}" + return base + + +def extract_email_from_credentials(connector_type: SearchSourceConnectorType, credentials: dict) -> str | None: + if connector_type == SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: + return credentials.get("email") or credentials.get("user_email") + if connector_type == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: + return credentials.get("email") + if connector_type == SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: + return credentials.get("email") + if connector_type == SearchSourceConnectorType.SLACK_CONNECTOR: + return credentials.get("team_name") or credentials.get("team_id") + if connector_type == SearchSourceConnectorType.NOTION_CONNECTOR: + return credentials.get("workspace_name") + if connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR: + return credentials.get("username") + if connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR: + return credentials.get("workspace_name") + if connector_type == SearchSourceConnectorType.JIRA_CONNECTOR: + return credentials.get("base_url") or credentials.get("cloud_id") + if connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR: + return credentials.get("base_url") or credentials.get("cloud_id") + if connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR: + return credentials.get("guild_name") + if connector_type == SearchSourceConnectorType.AIRTABLE_CONNECTOR: + return credentials.get("base_name") + if connector_type == SearchSourceConnectorType.LUMA_CONNECTOR: + return credentials.get("account_name") + for key in ["email", "username", "workspace_name", "team_name", "base_url", "guild_name", "site_name", "account_name"]: + if credentials.get(key): + return credentials.get(key) + return None + From 21d45b8b2139b49f560d741b29e8df08aab70a51 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 18:41:14 +0200 Subject: [PATCH 03/24] BE-1: Allow multiple connectors of same type per search space (remove duplicate checks, update docstrings) --- .../routes/search_source_connectors_routes.py | 20 +++---------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index d6fdedd7c..a92be5f6e 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -7,7 +7,7 @@ PUT /search-source-connectors/{connector_id} - Update a specific connector DELETE /search-source-connectors/{connector_id} - Delete a specific connector POST /search-source-connectors/{connector_id}/index - Index content from a connector to a search space -Note: Each search space can have only one connector of each type per user (based on search_space_id, user_id, and connector_type). +Note: Each search space can have multiple connectors of the same type per user (uniqueness is no longer enforced, you may connect several accounts of the same type). """ import logging @@ -111,7 +111,7 @@ async def create_search_source_connector( Create a new search source connector. Requires CONNECTORS_CREATE permission. - Each search space can have only one connector of each type (based on search_space_id and connector_type). + Each search space can have multiple connectors of the same type (e.g., multiple Gmail, Slack, etc. accounts). The config must contain the appropriate keys for the connector type. """ try: @@ -124,20 +124,6 @@ async def create_search_source_connector( "You don't have permission to create connectors in this search space", ) - # Check if a connector with the same type already exists for this search space - result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == search_space_id, - SearchSourceConnector.connector_type == connector.connector_type, - ) - ) - existing_connector = result.scalars().first() - if existing_connector: - raise HTTPException( - status_code=409, - detail=f"A connector with type {connector.connector_type} already exists in this search space.", - ) - # Prepare connector data connector_data = connector.model_dump() @@ -183,7 +169,7 @@ async def create_search_source_connector( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists in this search space. {e!s}", + detail=f"Integrity error: {e!s}", ) from e except HTTPException: await session.rollback() From d7b8890e9ec920738f7e985ce1a68e986008cc90 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 18:55:35 +0200 Subject: [PATCH 04/24] BE-2: Remove duplicate checks and auto-generate user-friendly names for Google connector OAuth callbacks (consistent comments, identifier extraction) --- .../google_calendar_add_connector_route.py | 27 ++++++++---------- .../google_drive_add_connector_route.py | 28 +++++++------------ .../google_gmail_add_connector_route.py | 26 +++++++---------- .../app/utils/connector_naming.py | 2 +- 4 files changed, 32 insertions(+), 51 deletions(-) diff --git a/surfsense_backend/app/routes/google_calendar_add_connector_route.py b/surfsense_backend/app/routes/google_calendar_add_connector_route.py index 6c6ae4e40..73d50cb7e 100644 --- a/surfsense_backend/app/routes/google_calendar_add_connector_route.py +++ b/surfsense_backend/app/routes/google_calendar_add_connector_route.py @@ -23,6 +23,7 @@ from app.db import ( ) from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -191,23 +192,17 @@ async def calendar_callback( creds_dict["_token_encrypted"] = True try: - # Check if a connector with the same type already exists for this search space and user - result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, - ) + + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, creds_dict + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, connector_identifier ) - existing_connector = result.scalars().first() - if existing_connector: - raise HTTPException( - status_code=409, - detail="A GOOGLE_CALENDAR_CONNECTOR connector already exists in this search space. Each search space can have only one connector of each type per user.", - ) db_connector = SearchSourceConnector( - name="Google Calendar Connector", + name=connector_name, connector_type=SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, config=creds_dict, search_space_id=space_id, @@ -231,7 +226,7 @@ async def calendar_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except HTTPException: await session.rollback() diff --git a/surfsense_backend/app/routes/google_drive_add_connector_route.py b/surfsense_backend/app/routes/google_drive_add_connector_route.py index 6caf3f204..3e9800ed1 100644 --- a/surfsense_backend/app/routes/google_drive_add_connector_route.py +++ b/surfsense_backend/app/routes/google_drive_add_connector_route.py @@ -37,6 +37,7 @@ from app.db import ( ) from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials # Relax token scope validation for Google OAuth os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" @@ -245,26 +246,17 @@ async def drive_callback( # Mark that credentials are encrypted for backward compatibility creds_dict["_token_encrypted"] = True - # Check if connector already exists for this space/user - result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, creds_dict + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, connector_identifier ) - existing_connector = result.scalars().first() - if existing_connector: - raise HTTPException( - status_code=409, - detail="A GOOGLE_DRIVE_CONNECTOR already exists in this search space. Each search space can have only one connector of each type per user.", - ) - - # Create new connector (NO folder selection here - happens at index time) db_connector = SearchSourceConnector( - name="Google Drive Connector", + name=connector_name, connector_type=SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, config={ **creds_dict, @@ -318,7 +310,7 @@ async def drive_callback( logger.error(f"Database integrity error: {e!s}", exc_info=True) raise HTTPException( status_code=409, - detail="A connector with this configuration already exists.", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: await session.rollback() diff --git a/surfsense_backend/app/routes/google_gmail_add_connector_route.py b/surfsense_backend/app/routes/google_gmail_add_connector_route.py index 20a51c1a1..01bca39f4 100644 --- a/surfsense_backend/app/routes/google_gmail_add_connector_route.py +++ b/surfsense_backend/app/routes/google_gmail_add_connector_route.py @@ -23,6 +23,7 @@ from app.db import ( ) from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -222,23 +223,16 @@ async def gmail_callback( creds_dict["_token_encrypted"] = True try: - # Check if a connector with the same type already exists for this search space and user - result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, creds_dict + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, connector_identifier ) - existing_connector = result.scalars().first() - if existing_connector: - raise HTTPException( - status_code=409, - detail="A GOOGLE_GMAIL_CONNECTOR connector already exists in this search space. Each search space can have only one connector of each type per user.", - ) db_connector = SearchSourceConnector( - name="Google Gmail Connector", + name=connector_name, connector_type=SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, config=creds_dict, search_space_id=space_id, @@ -264,7 +258,7 @@ async def gmail_callback( logger.error(f"Database integrity error: {e!s}") raise HTTPException( status_code=409, - detail="A connector with this configuration already exists.", + detail=f"Database integrity error: {e!s}", ) from e except ValidationError as e: await session.rollback() diff --git a/surfsense_backend/app/utils/connector_naming.py b/surfsense_backend/app/utils/connector_naming.py index 5081687ac..16c6d8f1e 100644 --- a/surfsense_backend/app/utils/connector_naming.py +++ b/surfsense_backend/app/utils/connector_naming.py @@ -28,7 +28,7 @@ def generate_unique_connector_name(connector_type: SearchSourceConnectorType, id return base -def extract_email_from_credentials(connector_type: SearchSourceConnectorType, credentials: dict) -> str | None: +def extract_identifier_from_credentials(connector_type: SearchSourceConnectorType, credentials: dict) -> str | None: if connector_type == SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: return credentials.get("email") or credentials.get("user_email") if connector_type == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: From 7900d6acc029f6900bfa97f8aa01eb5972dd99c3 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 19:05:22 +0200 Subject: [PATCH 05/24] BE-2: Remove duplicate checks and enable auto-generation of user-friendly names for Slack & Notion OAuth connectors --- .../app/routes/notion_add_connector_route.py | 55 ++++++++----------- .../app/routes/slack_add_connector_route.py | 54 ++++++++---------- 2 files changed, 44 insertions(+), 65 deletions(-) diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py index 462ac398c..832ca4abc 100644 --- a/surfsense_backend/app/routes/notion_add_connector_route.py +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -26,6 +26,7 @@ from app.db import ( from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -262,39 +263,27 @@ async def notion_callback( "_token_encrypted": True, } - # Check if connector already exists for this search space and user - existing_connector_result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.NOTION_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.NOTION_CONNECTOR, connector_config + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.NOTION_CONNECTOR, connector_identifier + ) + # Create new connector + new_connector = SearchSourceConnector( + name=connector_name, + connector_type=SearchSourceConnectorType.NOTION_CONNECTOR, + is_indexable=True, + config=connector_config, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Notion connector for user {user_id} in space {space_id}" ) - existing_connector = existing_connector_result.scalars().first() - - if existing_connector: - # Update existing connector - existing_connector.config = connector_config - existing_connector.name = "Notion Connector" - existing_connector.is_indexable = True - logger.info( - f"Updated existing Notion connector for user {user_id} in space {space_id}" - ) - else: - # Create new connector - new_connector = SearchSourceConnector( - name="Notion Connector", - connector_type=SearchSourceConnectorType.NOTION_CONNECTOR, - is_indexable=True, - config=connector_config, - search_space_id=space_id, - user_id=user_id, - ) - session.add(new_connector) - logger.info( - f"Created new Notion connector for user {user_id} in space {space_id}" - ) try: await session.commit() @@ -314,7 +303,7 @@ async def notion_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: logger.error(f"Failed to create search source connector: {e!s}") diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py index 71a362119..c0693f16f 100644 --- a/surfsense_backend/app/routes/slack_add_connector_route.py +++ b/surfsense_backend/app/routes/slack_add_connector_route.py @@ -26,6 +26,7 @@ from app.db import ( from app.schemas.slack_auth_credentials import SlackAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -272,39 +273,28 @@ async def slack_callback( "_token_encrypted": True, } - # Check if connector already exists for this search space and user - existing_connector_result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.SLACK_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.SLACK_CONNECTOR, connector_config + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.SLACK_CONNECTOR, connector_identifier ) - existing_connector = existing_connector_result.scalars().first() - if existing_connector: - # Update existing connector - existing_connector.config = connector_config - existing_connector.name = "Slack Connector" - existing_connector.is_indexable = True - logger.info( - f"Updated existing Slack connector for user {user_id} in space {space_id}" - ) - else: - # Create new connector - new_connector = SearchSourceConnector( - name="Slack Connector", - connector_type=SearchSourceConnectorType.SLACK_CONNECTOR, - is_indexable=True, - config=connector_config, - search_space_id=space_id, - user_id=user_id, - ) - session.add(new_connector) - logger.info( - f"Created new Slack connector for user {user_id} in space {space_id}" - ) + # Create new connector + new_connector = SearchSourceConnector( + name=connector_name, + connector_type=SearchSourceConnectorType.SLACK_CONNECTOR, + is_indexable=True, + config=connector_config, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Slack connector for user {user_id} in space {space_id}" + ) try: await session.commit() @@ -324,7 +314,7 @@ async def slack_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: logger.error(f"Failed to create search source connector: {e!s}") From c58a3fba55654442d245a4a5a94acb877113a96c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 19:12:18 +0200 Subject: [PATCH 06/24] BE-2: Remove duplicate logic and enable auto-friendly naming for Linear, Jira, and Discord connector OAuth callbacks --- .../app/routes/discord_add_connector_route.py | 55 ++++++++----------- .../app/routes/jira_add_connector_route.py | 55 ++++++++----------- .../app/routes/linear_add_connector_route.py | 55 ++++++++----------- 3 files changed, 66 insertions(+), 99 deletions(-) diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py index 6bebac718..73092f143 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -26,6 +26,7 @@ from app.db import ( from app.schemas.discord_auth_credentials import DiscordAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -284,39 +285,27 @@ async def discord_callback( "_token_encrypted": True, } - # Check if connector already exists for this search space and user - existing_connector_result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.DISCORD_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.DISCORD_CONNECTOR, connector_config + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.DISCORD_CONNECTOR, connector_identifier + ) + # Create new connector + new_connector = SearchSourceConnector( + name=connector_name, + connector_type=SearchSourceConnectorType.DISCORD_CONNECTOR, + is_indexable=True, + config=connector_config, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Discord connector for user {user_id} in space {space_id}" ) - existing_connector = existing_connector_result.scalars().first() - - if existing_connector: - # Update existing connector - existing_connector.config = connector_config - existing_connector.name = "Discord Connector" - existing_connector.is_indexable = True - logger.info( - f"Updated existing Discord connector for user {user_id} in space {space_id}" - ) - else: - # Create new connector - new_connector = SearchSourceConnector( - name="Discord Connector", - connector_type=SearchSourceConnectorType.DISCORD_CONNECTOR, - is_indexable=True, - config=connector_config, - search_space_id=space_id, - user_id=user_id, - ) - session.add(new_connector) - logger.info( - f"Created new Discord connector for user {user_id} in space {space_id}" - ) try: await session.commit() @@ -336,7 +325,7 @@ async def discord_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: logger.error(f"Failed to create search source connector: {e!s}") diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py index 740c30300..0d662f095 100644 --- a/surfsense_backend/app/routes/jira_add_connector_route.py +++ b/surfsense_backend/app/routes/jira_add_connector_route.py @@ -27,6 +27,7 @@ from app.db import ( from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -306,39 +307,27 @@ async def jira_callback( "_token_encrypted": True, } - # Check if connector already exists for this search space and user - existing_connector_result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.JIRA_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.JIRA_CONNECTOR, connector_config + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.JIRA_CONNECTOR, connector_identifier + ) + # Create new connector + new_connector = SearchSourceConnector( + name=connector_name, + connector_type=SearchSourceConnectorType.JIRA_CONNECTOR, + is_indexable=True, + config=connector_config, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Jira connector for user {user_id} in space {space_id}" ) - existing_connector = existing_connector_result.scalars().first() - - if existing_connector: - # Update existing connector - existing_connector.config = connector_config - existing_connector.name = "Jira Connector" - existing_connector.is_indexable = True - logger.info( - f"Updated existing Jira connector for user {user_id} in space {space_id}" - ) - else: - # Create new connector - new_connector = SearchSourceConnector( - name="Jira Connector", - connector_type=SearchSourceConnectorType.JIRA_CONNECTOR, - is_indexable=True, - config=connector_config, - search_space_id=space_id, - user_id=user_id, - ) - session.add(new_connector) - logger.info( - f"Created new Jira connector for user {user_id} in space {space_id}" - ) try: await session.commit() @@ -358,7 +347,7 @@ async def jira_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: logger.error(f"Failed to create search source connector: {e!s}") diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py index 7a7fc196a..e13c2dc5f 100644 --- a/surfsense_backend/app/routes/linear_add_connector_route.py +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -26,6 +26,7 @@ from app.db import ( from app.schemas.linear_auth_credentials import LinearAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -260,39 +261,27 @@ async def linear_callback( "_token_encrypted": True, } - # Check if connector already exists for this search space and user - existing_connector_result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.LINEAR_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.LINEAR_CONNECTOR, connector_config + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.LINEAR_CONNECTOR, connector_identifier + ) + # Create new connector + new_connector = SearchSourceConnector( + name=connector_name, + connector_type=SearchSourceConnectorType.LINEAR_CONNECTOR, + is_indexable=True, + config=connector_config, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Linear connector for user {user_id} in space {space_id}" ) - existing_connector = existing_connector_result.scalars().first() - - if existing_connector: - # Update existing connector - existing_connector.config = connector_config - existing_connector.name = "Linear Connector" - existing_connector.is_indexable = True - logger.info( - f"Updated existing Linear connector for user {user_id} in space {space_id}" - ) - else: - # Create new connector - new_connector = SearchSourceConnector( - name="Linear Connector", - connector_type=SearchSourceConnectorType.LINEAR_CONNECTOR, - is_indexable=True, - config=connector_config, - search_space_id=space_id, - user_id=user_id, - ) - session.add(new_connector) - logger.info( - f"Created new Linear connector for user {user_id} in space {space_id}" - ) try: await session.commit() @@ -312,7 +301,7 @@ async def linear_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: logger.error(f"Failed to create search source connector: {e!s}") From d75df7e5b209cb446a16587673c566fcb5fd093a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 19:26:40 +0200 Subject: [PATCH 07/24] BE-2: Remove duplicate-check logic and enable user-friendly auto-naming for Airtable and Confluence connector OAuth flows --- .../routes/airtable_add_connector_route.py | 55 ++++++++----------- .../routes/confluence_add_connector_route.py | 55 ++++++++----------- 2 files changed, 44 insertions(+), 66 deletions(-) diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py index 9284d89e8..7e3358c6f 100644 --- a/surfsense_backend/app/routes/airtable_add_connector_route.py +++ b/surfsense_backend/app/routes/airtable_add_connector_route.py @@ -23,6 +23,7 @@ from app.db import ( from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -297,39 +298,27 @@ async def airtable_callback( credentials_dict = credentials.to_dict() credentials_dict["_token_encrypted"] = True - # Check if connector already exists for this search space and user - existing_connector_result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.AIRTABLE_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.AIRTABLE_CONNECTOR, credentials_dict + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.AIRTABLE_CONNECTOR, connector_identifier + ) + # Create new connector + new_connector = SearchSourceConnector( + name=connector_name, + connector_type=SearchSourceConnectorType.AIRTABLE_CONNECTOR, + is_indexable=True, + config=credentials_dict, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Airtable connector for user {user_id} in space {space_id}" ) - existing_connector = existing_connector_result.scalars().first() - - if existing_connector: - # Update existing connector - existing_connector.config = credentials_dict - existing_connector.name = "Airtable Connector" - existing_connector.is_indexable = True - logger.info( - f"Updated existing Airtable connector for user {user_id} in space {space_id}" - ) - else: - # Create new connector - new_connector = SearchSourceConnector( - name="Airtable Connector", - connector_type=SearchSourceConnectorType.AIRTABLE_CONNECTOR, - is_indexable=True, - config=credentials_dict, - search_space_id=space_id, - user_id=user_id, - ) - session.add(new_connector) - logger.info( - f"Created new Airtable connector for user {user_id} in space {space_id}" - ) try: await session.commit() @@ -350,7 +339,7 @@ async def airtable_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: logger.error(f"Failed to create search source connector: {e!s}") diff --git a/surfsense_backend/app/routes/confluence_add_connector_route.py b/surfsense_backend/app/routes/confluence_add_connector_route.py index e86d411b6..a583c905a 100644 --- a/surfsense_backend/app/routes/confluence_add_connector_route.py +++ b/surfsense_backend/app/routes/confluence_add_connector_route.py @@ -26,6 +26,7 @@ from app.db import ( from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -288,39 +289,27 @@ async def confluence_callback( "_token_encrypted": True, } - # Check if connector already exists for this search space and user - existing_connector_result = await session.execute( - select(SearchSourceConnector).filter( - SearchSourceConnector.search_space_id == space_id, - SearchSourceConnector.user_id == user_id, - SearchSourceConnector.connector_type - == SearchSourceConnectorType.CONFLUENCE_CONNECTOR, - ) + # Extract unique identifier from connector credentials + connector_identifier = extract_identifier_from_credentials( + SearchSourceConnectorType.CONFLUENCE_CONNECTOR, connector_config + ) + # Generate a unique, user-friendly connector name from credentials/account info + connector_name = generate_unique_connector_name( + SearchSourceConnectorType.CONFLUENCE_CONNECTOR, connector_identifier + ) + # Create new connector + new_connector = SearchSourceConnector( + name=connector_name, + connector_type=SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + is_indexable=True, + config=connector_config, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Confluence connector for user {user_id} in space {space_id}" ) - existing_connector = existing_connector_result.scalars().first() - - if existing_connector: - # Update existing connector - existing_connector.config = connector_config - existing_connector.name = "Confluence Connector" - existing_connector.is_indexable = True - logger.info( - f"Updated existing Confluence connector for user {user_id} in space {space_id}" - ) - else: - # Create new connector - new_connector = SearchSourceConnector( - name="Confluence Connector", - connector_type=SearchSourceConnectorType.CONFLUENCE_CONNECTOR, - is_indexable=True, - config=connector_config, - search_space_id=space_id, - user_id=user_id, - ) - session.add(new_connector) - logger.info( - f"Created new Confluence connector for user {user_id} in space {space_id}" - ) try: await session.commit() @@ -340,7 +329,7 @@ async def confluence_callback( await session.rollback() raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {e!s}", + detail=f"Database integrity error: {e!s}", ) from e except Exception as e: logger.error(f"Failed to create search source connector: {e!s}") From d979c156f8043a9a48f5686067a7f287bffd52ad Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 6 Jan 2026 19:38:11 +0200 Subject: [PATCH 08/24] BE-2: Enforce unique connector names per user and search space (idempotent migration) --- ...58_unique_connector_name_per_space_user.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py diff --git a/surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py b/surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py new file mode 100644 index 000000000..b840af267 --- /dev/null +++ b/surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py @@ -0,0 +1,53 @@ +""" +Add unique constraint for (search_space_id, user_id, name) on search_source_connectors. + +Revision ID: 58 +Revises: 57 +Create Date: 2026-01-06 14:00:00.000000 + +""" + +from collections.abc import Sequence +from alembic import op + +revision: str = "58" +down_revision: str | None = "57" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +from sqlalchemy import text + +def upgrade() -> None: + connection = op.get_bind() + constraint_exists = connection.execute( + text(""" + SELECT 1 FROM information_schema.table_constraints + WHERE table_name='search_source_connectors' + AND constraint_type='UNIQUE' + AND constraint_name='uq_searchspace_user_connector_name' + """) + ).scalar() + if not constraint_exists: + op.create_unique_constraint( + "uq_searchspace_user_connector_name", + "search_source_connectors", + ["search_space_id", "user_id", "name"] + ) + +def downgrade() -> None: + connection = op.get_bind() + constraint_exists = connection.execute( + text(""" + SELECT 1 FROM information_schema.table_constraints + WHERE table_name='search_source_connectors' + AND constraint_type='UNIQUE' + AND constraint_name='uq_searchspace_user_connector_name' + """) + ).scalar() + if constraint_exists: + op.drop_constraint( + "uq_searchspace_user_connector_name", + "search_source_connectors", + type_="unique" + ) + From 4c6a782cec0dce1d47195a35312f2e3512eff8e0 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 08:15:48 +0200 Subject: [PATCH 09/24] feat: add extract_identifier_from_credentials to connector naming --- .../app/utils/connector_naming.py | 159 ++++++++++++++---- 1 file changed, 125 insertions(+), 34 deletions(-) diff --git a/surfsense_backend/app/utils/connector_naming.py b/surfsense_backend/app/utils/connector_naming.py index 16c6d8f1e..6f582cd87 100644 --- a/surfsense_backend/app/utils/connector_naming.py +++ b/surfsense_backend/app/utils/connector_naming.py @@ -1,4 +1,18 @@ -from app.db import SearchSourceConnectorType +""" +Connector Naming Utilities. + +Provides functions for generating unique, user-friendly connector names. +""" + +from typing import Any +from urllib.parse import urlparse +from uuid import UUID + +from sqlalchemy import func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.db import SearchSourceConnector, SearchSourceConnectorType # Friendly display names for connector types BASE_NAME_FOR_TYPE = { @@ -7,54 +21,131 @@ BASE_NAME_FOR_TYPE = { SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar", SearchSourceConnectorType.SLACK_CONNECTOR: "Slack", SearchSourceConnectorType.NOTION_CONNECTOR: "Notion", - SearchSourceConnectorType.GITHUB_CONNECTOR: "GitHub", SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear", SearchSourceConnectorType.JIRA_CONNECTOR: "Jira", SearchSourceConnectorType.DISCORD_CONNECTOR: "Discord", SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence", SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable", - SearchSourceConnectorType.LUMA_CONNECTOR: "Luma", - # Add other connectors as needed, fallback below } + def get_base_name_for_type(connector_type: SearchSourceConnectorType) -> str: + """Get a friendly display name for a connector type.""" return BASE_NAME_FOR_TYPE.get(connector_type, connector_type.replace("_", " ").title()) -def generate_unique_connector_name(connector_type: SearchSourceConnectorType, identifier: str | None) -> str: +def extract_identifier_from_credentials( + connector_type: SearchSourceConnectorType, + credentials: dict[str, Any], +) -> str | None: + """ + Extract a unique identifier from connector credentials. + + Args: + connector_type: The type of connector + credentials: The connector credentials dict + + Returns: + Identifier string (workspace name, email, etc.) or None + """ + if connector_type == SearchSourceConnectorType.SLACK_CONNECTOR: + return credentials.get("team_name") + + if connector_type == SearchSourceConnectorType.NOTION_CONNECTOR: + return credentials.get("workspace_name") + + if connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR: + return credentials.get("guild_name") + + if connector_type in ( + SearchSourceConnectorType.JIRA_CONNECTOR, + SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + ): + base_url = credentials.get("base_url", "") + if base_url: + try: + parsed = urlparse(base_url) + hostname = parsed.netloc or parsed.path + if ".atlassian.net" in hostname: + return hostname.replace(".atlassian.net", "") + return hostname + except Exception: + pass + return None + + # Google, Linear, Airtable require API calls - return None + return None + + +def generate_connector_name_with_identifier( + connector_type: SearchSourceConnectorType, + identifier: str | None, +) -> str: + """ + Generate a connector name with an identifier. + + Args: + connector_type: The type of connector + identifier: User identifier (email, workspace name, etc.) + + Returns: + Name like "Gmail - john@example.com" or just "Gmail" if no identifier + """ base = get_base_name_for_type(connector_type) if identifier: return f"{base} - {identifier}" return base -def extract_identifier_from_credentials(connector_type: SearchSourceConnectorType, credentials: dict) -> str | None: - if connector_type == SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR: - return credentials.get("email") or credentials.get("user_email") - if connector_type == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: - return credentials.get("email") - if connector_type == SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: - return credentials.get("email") - if connector_type == SearchSourceConnectorType.SLACK_CONNECTOR: - return credentials.get("team_name") or credentials.get("team_id") - if connector_type == SearchSourceConnectorType.NOTION_CONNECTOR: - return credentials.get("workspace_name") - if connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR: - return credentials.get("username") - if connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR: - return credentials.get("workspace_name") - if connector_type == SearchSourceConnectorType.JIRA_CONNECTOR: - return credentials.get("base_url") or credentials.get("cloud_id") - if connector_type == SearchSourceConnectorType.CONFLUENCE_CONNECTOR: - return credentials.get("base_url") or credentials.get("cloud_id") - if connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR: - return credentials.get("guild_name") - if connector_type == SearchSourceConnectorType.AIRTABLE_CONNECTOR: - return credentials.get("base_name") - if connector_type == SearchSourceConnectorType.LUMA_CONNECTOR: - return credentials.get("account_name") - for key in ["email", "username", "workspace_name", "team_name", "base_url", "guild_name", "site_name", "account_name"]: - if credentials.get(key): - return credentials.get(key) - return None +async def count_connectors_of_type( + session: AsyncSession, + connector_type: SearchSourceConnectorType, + search_space_id: int, + user_id: UUID, +) -> int: + """Count existing connectors of a type for a user in a search space.""" + result = await session.execute( + select(func.count(SearchSourceConnector.id)).where( + SearchSourceConnector.connector_type == connector_type, + SearchSourceConnector.search_space_id == search_space_id, + SearchSourceConnector.user_id == user_id, + ) + ) + return result.scalar() or 0 + + +async def generate_unique_connector_name( + session: AsyncSession, + connector_type: SearchSourceConnectorType, + search_space_id: int, + user_id: UUID, + identifier: str | None = None, +) -> str: + """ + Generate a unique connector name. + + If an identifier is provided (email, workspace name, etc.), uses it with base name. + Otherwise, falls back to counting existing connectors for uniqueness. + + Args: + session: Database session + connector_type: The type of connector + search_space_id: The search space ID + user_id: The user ID + identifier: Optional user identifier (email, workspace name, etc.) + + Returns: + Unique name like "Gmail - john@example.com" or "Gmail (2)" + """ + base = get_base_name_for_type(connector_type) + + if identifier: + return f"{base} - {identifier}" + + # Fallback: use counter for uniqueness + count = await count_connectors_of_type(session, connector_type, search_space_id, user_id) + + if count == 0: + return base + return f"{base} ({count + 1})" From 932222bff14667c2e83b4360a7d0fe8cf2039f2c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 08:15:56 +0200 Subject: [PATCH 10/24] feat: add fetch_google_user_email and update Google OAuth routes --- .../app/connectors/google_gmail_connector.py | 29 +++++++++++++++++++ .../google_calendar_add_connector_route.py | 21 ++++++++------ .../google_drive_add_connector_route.py | 20 ++++++++----- .../google_gmail_add_connector_route.py | 20 ++++++++----- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/surfsense_backend/app/connectors/google_gmail_connector.py b/surfsense_backend/app/connectors/google_gmail_connector.py index 402337448..10008ad73 100644 --- a/surfsense_backend/app/connectors/google_gmail_connector.py +++ b/surfsense_backend/app/connectors/google_gmail_connector.py @@ -6,6 +6,7 @@ Allows fetching emails from Gmail mailbox using Google OAuth credentials. import base64 import json +import logging import re from typing import Any @@ -21,6 +22,34 @@ from app.db import ( SearchSourceConnectorType, ) +logger = logging.getLogger(__name__) + + +def fetch_google_user_email(credentials: Credentials) -> str | None: + """ + Fetch user email from Gmail API using Google credentials. + + Uses the Gmail users.getProfile endpoint which returns the authenticated + user's email address. + + Args: + credentials: Google OAuth Credentials object (not encrypted) + + Returns: + User's email address or None if fetch fails + """ + try: + service = build("gmail", "v1", credentials=credentials) + profile = service.users().getProfile(userId="me").execute() + email = profile.get("emailAddress") + if email: + logger.debug(f"Fetched Google user email: {email}") + return email + return None + except Exception as e: + logger.warning(f"Error fetching Google user email: {e!s}") + return None + class GoogleGmailConnector: """Class for retrieving emails from Gmail using Google OAuth credentials.""" diff --git a/surfsense_backend/app/routes/google_calendar_add_connector_route.py b/surfsense_backend/app/routes/google_calendar_add_connector_route.py index 73d50cb7e..7210efae0 100644 --- a/surfsense_backend/app/routes/google_calendar_add_connector_route.py +++ b/surfsense_backend/app/routes/google_calendar_add_connector_route.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.config import config +from app.connectors.google_gmail_connector import fetch_google_user_email from app.db import ( SearchSourceConnector, SearchSourceConnectorType, @@ -22,8 +23,8 @@ from app.db import ( get_async_session, ) from app.users import current_active_user +from app.utils.connector_naming import generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -173,6 +174,9 @@ async def calendar_callback( creds = flow.credentials creds_dict = json.loads(creds.to_json()) + # Fetch user email before encrypting credentials + user_email = fetch_google_user_email(creds) + # Encrypt sensitive credentials before storing token_encryption = get_token_encryption() @@ -192,14 +196,13 @@ async def calendar_callback( creds_dict["_token_encrypted"] = True try: - - # Extract unique identifier from connector credentials - connector_identifier = extract_identifier_from_credentials( - SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, creds_dict - ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + space_id, + user_id, + user_email, ) db_connector = SearchSourceConnector( name=connector_name, diff --git a/surfsense_backend/app/routes/google_drive_add_connector_route.py b/surfsense_backend/app/routes/google_drive_add_connector_route.py index 3e9800ed1..e63e4df30 100644 --- a/surfsense_backend/app/routes/google_drive_add_connector_route.py +++ b/surfsense_backend/app/routes/google_drive_add_connector_route.py @@ -29,6 +29,7 @@ from app.connectors.google_drive import ( get_start_page_token, list_folder_contents, ) +from app.connectors.google_gmail_connector import fetch_google_user_email from app.db import ( SearchSourceConnector, SearchSourceConnectorType, @@ -36,8 +37,8 @@ from app.db import ( get_async_session, ) from app.users import current_active_user +from app.utils.connector_naming import generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials # Relax token scope validation for Google OAuth os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" @@ -228,6 +229,9 @@ async def drive_callback( creds = flow.credentials creds_dict = json.loads(creds.to_json()) + # Fetch user email before encrypting credentials + user_email = fetch_google_user_email(creds) + # Encrypt sensitive credentials before storing token_encryption = get_token_encryption() @@ -246,13 +250,13 @@ async def drive_callback( # Mark that credentials are encrypted for backward compatibility creds_dict["_token_encrypted"] = True - # Extract unique identifier from connector credentials - connector_identifier = extract_identifier_from_credentials( - SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, creds_dict - ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + space_id, + user_id, + user_email, ) db_connector = SearchSourceConnector( diff --git a/surfsense_backend/app/routes/google_gmail_add_connector_route.py b/surfsense_backend/app/routes/google_gmail_add_connector_route.py index 01bca39f4..a6071ca53 100644 --- a/surfsense_backend/app/routes/google_gmail_add_connector_route.py +++ b/surfsense_backend/app/routes/google_gmail_add_connector_route.py @@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.config import config +from app.connectors.google_gmail_connector import fetch_google_user_email from app.db import ( SearchSourceConnector, SearchSourceConnectorType, @@ -22,8 +23,8 @@ from app.db import ( get_async_session, ) from app.users import current_active_user +from app.utils.connector_naming import generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -204,6 +205,9 @@ async def gmail_callback( creds = flow.credentials creds_dict = json.loads(creds.to_json()) + # Fetch user email before encrypting credentials + user_email = fetch_google_user_email(creds) + # Encrypt sensitive credentials before storing token_encryption = get_token_encryption() @@ -223,13 +227,13 @@ async def gmail_callback( creds_dict["_token_encrypted"] = True try: - # Extract unique identifier from connector credentials - connector_identifier = extract_identifier_from_credentials( - SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, creds_dict - ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + space_id, + user_id, + user_email, ) db_connector = SearchSourceConnector( name=connector_name, From d03b8dae34088a931542b50058ad116c34dc43f2 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 08:16:04 +0200 Subject: [PATCH 11/24] feat: add Linear org name fetch and update route --- .../app/connectors/linear_connector.py | 1 + .../app/connectors/linear_oauth.py | 60 +++++++++++++++++++ .../app/routes/linear_add_connector_route.py | 20 ++++--- 3 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 surfsense_backend/app/connectors/linear_oauth.py diff --git a/surfsense_backend/app/connectors/linear_connector.py b/surfsense_backend/app/connectors/linear_connector.py index 148aa4d0a..404f60e66 100644 --- a/surfsense_backend/app/connectors/linear_connector.py +++ b/surfsense_backend/app/connectors/linear_connector.py @@ -592,3 +592,4 @@ class LinearConnector: return dt.strftime("%Y-%m-%d %H:%M:%S") except ValueError: return iso_date + diff --git a/surfsense_backend/app/connectors/linear_oauth.py b/surfsense_backend/app/connectors/linear_oauth.py new file mode 100644 index 000000000..96336fe94 --- /dev/null +++ b/surfsense_backend/app/connectors/linear_oauth.py @@ -0,0 +1,60 @@ +""" +Linear OAuth Utilities. + +Provides functions for fetching user/organization info from Linear API. +Separated from linear_connector.py to avoid circular imports. +""" + +import logging + +import httpx + +logger = logging.getLogger(__name__) + +LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" + +ORGANIZATION_QUERY = """ +query { + organization { + name + } +} +""" + + +async def fetch_linear_organization_name(access_token: str) -> str | None: + """ + Fetch organization/workspace name from Linear GraphQL API. + + Args: + access_token: The Linear OAuth access token + + Returns: + Organization name or None if fetch fails + """ + try: + async with httpx.AsyncClient() as client: + response = await client.post( + LINEAR_GRAPHQL_URL, + headers={ + "Authorization": access_token, + "Content-Type": "application/json", + }, + json={"query": ORGANIZATION_QUERY}, + timeout=10.0, + ) + + if response.status_code == 200: + data = response.json() + org_name = data.get("data", {}).get("organization", {}).get("name") + if org_name: + logger.debug(f"Fetched Linear organization name: {org_name}") + return org_name + + logger.warning(f"Failed to fetch Linear org info: {response.status_code}") + return None + + except Exception as e: + logger.warning(f"Error fetching Linear organization name: {e!s}") + return None + diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py index e13c2dc5f..73bf500a3 100644 --- a/surfsense_backend/app/routes/linear_add_connector_route.py +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -23,10 +23,11 @@ from app.db import ( User, get_async_session, ) +from app.connectors.linear_oauth import fetch_linear_organization_name from app.schemas.linear_auth_credentials import LinearAuthCredentialsBase from app.users import current_active_user +from app.utils.connector_naming import generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -241,6 +242,9 @@ async def linear_callback( status_code=400, detail="No access token received from Linear" ) + # Fetch organization name before encrypting credentials + org_name = await fetch_linear_organization_name(access_token) + # Calculate expiration time (UTC, tz-aware) expires_at = None if token_json.get("expires_in"): @@ -261,13 +265,13 @@ async def linear_callback( "_token_encrypted": True, } - # Extract unique identifier from connector credentials - connector_identifier = extract_identifier_from_credentials( - SearchSourceConnectorType.LINEAR_CONNECTOR, connector_config - ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.LINEAR_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.LINEAR_CONNECTOR, + space_id, + user_id, + org_name, ) # Create new connector new_connector = SearchSourceConnector( From 42397f1364adfa51c419eeb7007808501b1f7430 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 08:16:11 +0200 Subject: [PATCH 12/24] feat: add Airtable user email fetch and update route --- .../app/connectors/airtable_connector.py | 43 +++++++++++++++++++ .../routes/airtable_add_connector_route.py | 21 +++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/surfsense_backend/app/connectors/airtable_connector.py b/surfsense_backend/app/connectors/airtable_connector.py index 840b2276c..ecbba7a19 100644 --- a/surfsense_backend/app/connectors/airtable_connector.py +++ b/surfsense_backend/app/connectors/airtable_connector.py @@ -382,3 +382,46 @@ class AirtableConnector: markdown_parts.append("") return "\n".join(markdown_parts) + + +# --- OAuth User Info --- + +AIRTABLE_WHOAMI_URL = "https://api.airtable.com/v0/meta/whoami" + + +async def fetch_airtable_user_email(access_token: str) -> str | None: + """ + Fetch user email from Airtable whoami API. + + Args: + access_token: The Airtable OAuth access token + + Returns: + User's email address or None if fetch fails + """ + import httpx + import logging + + logger = logging.getLogger(__name__) + + try: + async with httpx.AsyncClient() as client: + response = await client.get( + AIRTABLE_WHOAMI_URL, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=10.0, + ) + + if response.status_code == 200: + data = response.json() + email = data.get("email") + if email: + logger.debug(f"Fetched Airtable user email: {email}") + return email + + logger.warning(f"Failed to fetch Airtable user info: {response.status_code}") + return None + + except Exception as e: + logger.warning(f"Error fetching Airtable user email: {e!s}") + return None diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py index 7e3358c6f..9632c9308 100644 --- a/surfsense_backend/app/routes/airtable_add_connector_route.py +++ b/surfsense_backend/app/routes/airtable_add_connector_route.py @@ -20,10 +20,11 @@ from app.db import ( User, get_async_session, ) +from app.connectors.airtable_connector import fetch_airtable_user_email from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase from app.users import current_active_user +from app.utils.connector_naming import generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -276,6 +277,10 @@ async def airtable_callback( status_code=400, detail="No access token received from Airtable" ) + # Fetch user email before encrypting credentials + user_email = await fetch_airtable_user_email(access_token) + + # Calculate expiration time (UTC, tz-aware) expires_at = None if token_json.get("expires_in"): @@ -298,13 +303,13 @@ async def airtable_callback( credentials_dict = credentials.to_dict() credentials_dict["_token_encrypted"] = True - # Extract unique identifier from connector credentials - connector_identifier = extract_identifier_from_credentials( - SearchSourceConnectorType.AIRTABLE_CONNECTOR, credentials_dict - ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.AIRTABLE_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.AIRTABLE_CONNECTOR, + space_id, + user_id, + user_email, ) # Create new connector new_connector = SearchSourceConnector( From 0ba64fe6c457f006870f3050772ba01aff8aa347 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 08:16:19 +0200 Subject: [PATCH 13/24] feat: update OAuth routes to use async connector naming --- .../app/routes/confluence_add_connector_route.py | 15 +++++++++++---- .../app/routes/discord_add_connector_route.py | 15 +++++++++++---- .../app/routes/jira_add_connector_route.py | 15 +++++++++++---- .../app/routes/notion_add_connector_route.py | 15 +++++++++++---- .../app/routes/slack_add_connector_route.py | 15 +++++++++++---- 5 files changed, 55 insertions(+), 20 deletions(-) diff --git a/surfsense_backend/app/routes/confluence_add_connector_route.py b/surfsense_backend/app/routes/confluence_add_connector_route.py index a583c905a..7c2a0e2ca 100644 --- a/surfsense_backend/app/routes/confluence_add_connector_route.py +++ b/surfsense_backend/app/routes/confluence_add_connector_route.py @@ -26,7 +26,10 @@ from app.db import ( from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials +from app.utils.connector_naming import ( + extract_identifier_from_credentials, + generate_unique_connector_name, +) logger = logging.getLogger(__name__) @@ -293,9 +296,13 @@ async def confluence_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.CONFLUENCE_CONNECTOR, connector_config ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.CONFLUENCE_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + space_id, + user_id, + connector_identifier, ) # Create new connector new_connector = SearchSourceConnector( diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py index 73092f143..d32902730 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -26,7 +26,10 @@ from app.db import ( from app.schemas.discord_auth_credentials import DiscordAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials +from app.utils.connector_naming import ( + extract_identifier_from_credentials, + generate_unique_connector_name, +) logger = logging.getLogger(__name__) @@ -289,9 +292,13 @@ async def discord_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.DISCORD_CONNECTOR, connector_config ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.DISCORD_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.DISCORD_CONNECTOR, + space_id, + user_id, + connector_identifier, ) # Create new connector new_connector = SearchSourceConnector( diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py index 0d662f095..4cb595058 100644 --- a/surfsense_backend/app/routes/jira_add_connector_route.py +++ b/surfsense_backend/app/routes/jira_add_connector_route.py @@ -27,7 +27,10 @@ from app.db import ( from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials +from app.utils.connector_naming import ( + extract_identifier_from_credentials, + generate_unique_connector_name, +) logger = logging.getLogger(__name__) @@ -311,9 +314,13 @@ async def jira_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.JIRA_CONNECTOR, connector_config ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.JIRA_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.JIRA_CONNECTOR, + space_id, + user_id, + connector_identifier, ) # Create new connector new_connector = SearchSourceConnector( diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py index 832ca4abc..251814d58 100644 --- a/surfsense_backend/app/routes/notion_add_connector_route.py +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -26,7 +26,10 @@ from app.db import ( from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials +from app.utils.connector_naming import ( + extract_identifier_from_credentials, + generate_unique_connector_name, +) logger = logging.getLogger(__name__) @@ -267,9 +270,13 @@ async def notion_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.NOTION_CONNECTOR, connector_config ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.NOTION_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.NOTION_CONNECTOR, + space_id, + user_id, + connector_identifier, ) # Create new connector new_connector = SearchSourceConnector( diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py index c0693f16f..50c505a78 100644 --- a/surfsense_backend/app/routes/slack_add_connector_route.py +++ b/surfsense_backend/app/routes/slack_add_connector_route.py @@ -25,8 +25,11 @@ from app.db import ( ) from app.schemas.slack_auth_credentials import SlackAuthCredentialsBase from app.users import current_active_user +from app.utils.connector_naming import ( + extract_identifier_from_credentials, + generate_unique_connector_name, +) from app.utils.oauth_security import OAuthStateManager, TokenEncryption -from app.utils.connector_naming import generate_unique_connector_name, extract_identifier_from_credentials logger = logging.getLogger(__name__) @@ -277,9 +280,13 @@ async def slack_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.SLACK_CONNECTOR, connector_config ) - # Generate a unique, user-friendly connector name from credentials/account info - connector_name = generate_unique_connector_name( - SearchSourceConnectorType.SLACK_CONNECTOR, connector_identifier + # Generate a unique, user-friendly connector name + connector_name = await generate_unique_connector_name( + session, + SearchSourceConnectorType.SLACK_CONNECTOR, + space_id, + user_id, + connector_identifier, ) # Create new connector From 93c7b83a06d8f06e9e2a4dfac5d1dc83b4cc2fb8 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 08:16:27 +0200 Subject: [PATCH 14/24] feat: show identifier-only display names in connector cards --- .../tabs/active-connectors-tab.tsx | 3 +- .../tabs/all-connectors-tab.tsx | 127 +++++++++++------- 2 files changed, 84 insertions(+), 46 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index 3dd4fd1d0..04e819bc8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -12,6 +12,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; +import { getConnectorDisplayName } from "./all-connectors-tab"; interface ActiveConnectorsTabProps { searchQuery: string; @@ -171,7 +172,7 @@ export const ActiveConnectorsTab: FC = ({

- {connector.name} + {getConnectorDisplayName(connector.name)}

{isIndexing ? (

diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index bdec4dcb2..0be4e7e87 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -1,12 +1,27 @@ "use client"; +import { Plus } from "lucide-react"; import type { FC } from "react"; +import { Button } from "@/components/ui/button"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import { ConnectorCard } from "../components/connector-card"; import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; +/** + * Extract the display name from a full connector name. + * Full names are in format "Base Name - identifier" (e.g., "Gmail - john@example.com"). + * Returns just the identifier (e.g : john@example.com). + */ +export function getConnectorDisplayName(fullName: string): string { + const separatorIndex = fullName.indexOf(" - "); + if (separatorIndex !== -1) { + return fullName.substring(separatorIndex + 3); + } + return fullName; +} + interface AllConnectorsTabProps { searchQuery: string; searchSpaceId: string; @@ -67,56 +82,78 @@ export const AllConnectorsTab: FC = ({ return (

- {/* Quick Connect */} - {filteredOAuth.length > 0 && ( -
-
-

Quick Connect

-
-
- {filteredOAuth.map((connector) => { - const isConnected = connectedTypes.has(connector.connectorType); - const isConnecting = connectingId === connector.id; - // Find the actual connector object if connected - const actualConnector = - isConnected && allConnectors - ? allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === connector.connectorType - ) - : undefined; + {/* Per-Type OAuth Connector Groups */} + {filteredOAuth.map((connectorType) => { + const userConnectors = + allConnectors?.filter( + (c: SearchSourceConnector) => c.connector_type === connectorType.connectorType + ) || []; + const isConnecting = connectingId === connectorType.id; - const documentCount = getDocumentCountForConnector( - connector.connectorType, - documentTypeCounts - ); - const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id); - const activeTask = actualConnector - ? getActiveTaskForConnector(actualConnector.id) - : undefined; + return ( +
+ {/* Group Header */} +
+

+ {connectorType.title} Integrations +

+ {userConnectors.length > 0 && ( + + )} +
- return ( +
+ {userConnectors.length === 0 ? ( onConnectOAuth(connector)} - onManage={ - actualConnector && onManage ? () => onManage(actualConnector) : undefined - } + onConnect={() => onConnectOAuth(connectorType)} /> - ); - })} -
-
- )} + ) : ( + userConnectors.map((connector: SearchSourceConnector) => { + const documentCount = getDocumentCountForConnector( + connector.connector_type, + documentTypeCounts + ); + const isIndexing = indexingConnectorIds?.has(connector.id); + const activeTask = getActiveTaskForConnector(connector.id); + + return ( + onConnectOAuth(connectorType)} + onManage={onManage ? () => onManage(connector) : undefined} + /> + ); + }) + )} +
+
+ ); + })} {/* More Integrations */} {filteredOther.length > 0 && ( From 755f92323a9303c4052a4c1fead34e126f9e0475 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 08:36:55 +0200 Subject: [PATCH 15/24] fix: connector card and edit view styling --- .../connector-popup/components/connector-card.tsx | 6 +++--- .../connector-configs/views/connector-edit-view.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index 855be95a2..faf20e055 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -139,7 +139,7 @@ export const ConnectorCard: FC = ({ return (
-
+
{connectorType ? ( getConnectorIcon(connectorType, "size-6") ) : id === "youtube-crawler" ? ( @@ -150,7 +150,7 @@ export const ConnectorCard: FC = ({
- {title} + {title}
{getStatusContent()}
{isConnected && documentCount !== undefined && ( @@ -163,7 +163,7 @@ export const ConnectorCard: FC = ({ size="sm" variant={isConnected ? "secondary" : "default"} className={cn( - "h-8 text-[11px] px-3 rounded-lg flex-shrink-0 font-medium", + "h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium", isConnected && "bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80", !isConnected && "shadow-xs" diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index 7776c9a9d..e09bdea90 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -143,12 +143,12 @@ export const ConnectorEditView: FC = ({ {/* Connector header */}
-
-
+
+
{getConnectorIcon(connector.connector_type, "size-7")}
-

{connector.name}

+

{connector.name}

Manage your connector settings and sync configuration

From 2508b37f4ef7c576d41722e0d0d3e5c6850a7a32 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 09:28:07 +0200 Subject: [PATCH 16/24] feat: add connector accounts list view for OAuth connectors with multiple accounts - Create ConnectorAccountsListView component to show all connected accounts for a connector type - Add state management in use-connector-dialog for viewing connector accounts list - Update AllConnectorsTab to show accounts list when OAuth connector is connected - Update connector-popup.tsx to render the new accounts list view - Add 'accounts' view to connector popup URL schema - Display connected accounts in 2-column grid layout - Add 'Add Account' button with dashed border in header --- .../assistant-ui/connector-popup.tsx | 27 + .../constants/connector-popup.schemas.ts | 2 +- .../hooks/use-connector-dialog.ts | 100 +++- .../tabs/all-connectors-tab.tsx | 498 +++++++++--------- .../views/connector-accounts-list-view.tsx | 214 ++++++++ 5 files changed, 595 insertions(+), 246 deletions(-) create mode 100644 surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 8fb1e7652..c5e996c4c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -19,9 +19,11 @@ import { ConnectorDialogHeader } from "./connector-popup/components/connector-di import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view"; +import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants"; import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog"; import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab"; import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; +import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view"; export const ConnectorIndicator: FC = () => { @@ -60,6 +62,7 @@ export const ConnectorIndicator: FC = () => { periodicEnabled, frequencyMinutes, allConnectors, + viewingAccountsType, setSearchQuery, setStartDate, setEndDate, @@ -81,6 +84,9 @@ export const ConnectorIndicator: FC = () => { handleBackFromEdit, handleBackFromConnect, handleBackFromYouTube, + handleViewAccountsList, + handleBackFromAccountsList, + handleAddAccountOAuth, handleQuickIndexConnector, connectorConfig, setConnectorConfig, @@ -193,6 +199,26 @@ export const ConnectorIndicator: FC = () => { {/* YouTube Crawler View - shown when adding YouTube videos */} {isYouTubeView && searchSpaceId ? ( + ) : viewingAccountsType ? ( + { + const oauthConnector = OAUTH_CONNECTORS.find( + (c) => c.connectorType === viewingAccountsType.connectorType + ); + if (oauthConnector) { + handleAddAccountOAuth(oauthConnector); + } + }} + isConnecting={connectingId !== null} + /> ) : connectingConnectorType ? ( { onCreateWebcrawler={handleCreateWebcrawler} onCreateYouTubeCrawler={handleCreateYouTubeCrawler} onManage={handleStartEdit} + onViewAccountsList={handleViewAccountsList} /> diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts index 65456689c..808c7b428 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts @@ -7,7 +7,7 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types export const connectorPopupQueryParamsSchema = z.object({ modal: z.enum(["connectors"]).optional(), tab: z.enum(["all", "active"]).optional(), - view: z.enum(["configure", "edit", "connect", "youtube"]).optional(), + view: z.enum(["configure", "edit", "connect", "youtube", "accounts"]).optional(), connector: z.string().optional(), connectorId: z.string().optional(), connectorType: z.string().optional(), diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 8ddaa973a..a9d4871e1 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -66,6 +66,12 @@ export const useConnectorDialog = () => { const [isCreatingConnector, setIsCreatingConnector] = useState(false); const isCreatingConnectorRef = useRef(false); + // Accounts list view state (for OAuth connectors with multiple accounts) + const [viewingAccountsType, setViewingAccountsType] = useState<{ + connectorType: string; + connectorTitle: string; + } | null>(null); + // Helper function to get frequency label const getFrequencyLabel = useCallback((minutes: string): string => { switch (minutes) { @@ -114,11 +120,29 @@ export const useConnectorDialog = () => { setConnectingConnectorType(null); } + // Clear viewing accounts type if view is not "accounts" anymore + if (params.view !== "accounts" && viewingAccountsType) { + setViewingAccountsType(null); + } + // Handle connect view if (params.view === "connect" && params.connectorType && !connectingConnectorType) { setConnectingConnectorType(params.connectorType); } + // Handle accounts view + if (params.view === "accounts" && params.connectorType && !viewingAccountsType) { + const oauthConnector = OAUTH_CONNECTORS.find( + (c) => c.connectorType === params.connectorType + ); + if (oauthConnector) { + setViewingAccountsType({ + connectorType: oauthConnector.connectorType, + connectorTitle: oauthConnector.title, + }); + } + } + // Handle YouTube view if (params.view === "youtube") { // YouTube view is active - no additional state needed @@ -200,6 +224,10 @@ export const useConnectorDialog = () => { if (connectingConnectorType) { setConnectingConnectorType(null); } + // Clear viewing accounts type when modal is closed + if (viewingAccountsType) { + setViewingAccountsType(null); + } // Clear YouTube view when modal is closed (handled by view param check) } } catch (error) { @@ -207,7 +235,7 @@ export const useConnectorDialog = () => { console.warn("Invalid connector popup query params:", error); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType]); + }, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType, viewingAccountsType]); // Detect OAuth success and transition to config view useEffect(() => { @@ -632,6 +660,71 @@ export const useConnectorDialog = () => { router.replace(url.pathname + url.search, { scroll: false }); }, [router]); + // Handle viewing accounts list for OAuth connector type + const handleViewAccountsList = useCallback( + (connector: (typeof OAUTH_CONNECTORS)[number]) => { + if (!searchSpaceId) return; + + setViewingAccountsType({ + connectorType: connector.connectorType, + connectorTitle: connector.title, + }); + + // Update URL to show accounts view + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("view", "accounts"); + url.searchParams.set("connectorType", connector.connectorType); + window.history.pushState({ modal: true }, "", url.toString()); + }, + [searchSpaceId] + ); + + // Handle going back from accounts list view + const handleBackFromAccountsList = useCallback(() => { + setViewingAccountsType(null); + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", "all"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorType"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); + + // Handle adding a new account for OAuth connector (from accounts list view) + const handleAddAccountOAuth = useCallback( + async (connector: (typeof OAUTH_CONNECTORS)[number]) => { + if (!searchSpaceId || !connector.authEndpoint) return; + + // Set connecting state + setConnectingId(connector.id); + + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`Failed to initiate ${connector.title} OAuth`); + } + + const data = await response.json(); + const validatedData = parseOAuthAuthResponse(data); + window.location.href = validatedData.auth_url; + } catch (error) { + console.error(`Error connecting to ${connector.title}:`, error); + if (error instanceof Error && error.message.includes("Invalid auth URL")) { + toast.error(`Invalid response from ${connector.title} OAuth endpoint`); + } else { + toast.error(`Failed to connect to ${connector.title}`); + } + setConnectingId(null); + } + }, + [searchSpaceId] + ); + // Handle starting indexing const handleStartIndexing = useCallback( async (refreshConnectors: () => void) => { @@ -1081,6 +1174,7 @@ export const useConnectorDialog = () => { setConnectorName(null); setConnectorConfig(null); setConnectingConnectorType(null); + setViewingAccountsType(null); setStartDate(undefined); setEndDate(undefined); setPeriodicEnabled(false); @@ -1126,6 +1220,7 @@ export const useConnectorDialog = () => { frequencyMinutes, searchSpaceId, allConnectors, + viewingAccountsType, // Setters setSearchQuery, @@ -1152,6 +1247,9 @@ export const useConnectorDialog = () => { handleBackFromEdit, handleBackFromConnect, handleBackFromYouTube, + handleViewAccountsList, + handleBackFromAccountsList, + handleAddAccountOAuth, handleQuickIndexConnector, connectorConfig, setConnectorConfig, diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 0be4e7e87..5356a2afd 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -6,7 +6,11 @@ import { Button } from "@/components/ui/button"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import { ConnectorCard } from "../components/connector-card"; -import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants"; +import { + CRAWLERS, + OAUTH_CONNECTORS, + OTHER_CONNECTORS, +} from "../constants/connector-constants"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; /** @@ -15,271 +19,277 @@ import { getDocumentCountForConnector } from "../utils/connector-document-mappin * Returns just the identifier (e.g : john@example.com). */ export function getConnectorDisplayName(fullName: string): string { - const separatorIndex = fullName.indexOf(" - "); - if (separatorIndex !== -1) { - return fullName.substring(separatorIndex + 3); - } - return fullName; + const separatorIndex = fullName.indexOf(" - "); + if (separatorIndex !== -1) { + return fullName.substring(separatorIndex + 3); + } + return fullName; } interface AllConnectorsTabProps { - searchQuery: string; - searchSpaceId: string; - connectedTypes: Set; - connectingId: string | null; - allConnectors: SearchSourceConnector[] | undefined; - documentTypeCounts?: Record; - indexingConnectorIds?: Set; - logsSummary?: LogSummary; - onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void; - onConnectNonOAuth?: (connectorType: string) => void; - onCreateWebcrawler?: () => void; - onCreateYouTubeCrawler?: () => void; - onManage?: (connector: SearchSourceConnector) => void; + searchQuery: string; + searchSpaceId: string; + connectedTypes: Set; + connectingId: string | null; + allConnectors: SearchSourceConnector[] | undefined; + documentTypeCounts?: Record; + indexingConnectorIds?: Set; + logsSummary?: LogSummary; + onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void; + onConnectNonOAuth?: (connectorType: string) => void; + onCreateWebcrawler?: () => void; + onCreateYouTubeCrawler?: () => void; + onManage?: (connector: SearchSourceConnector) => void; + onViewAccountsList?: (connector: (typeof OAUTH_CONNECTORS)[number]) => void; } export const AllConnectorsTab: FC = ({ - searchQuery, - searchSpaceId, - connectedTypes, - connectingId, - allConnectors, - documentTypeCounts, - indexingConnectorIds, - logsSummary, - onConnectOAuth, - onConnectNonOAuth, - onCreateWebcrawler, - onCreateYouTubeCrawler, - onManage, + searchQuery, + searchSpaceId, + connectedTypes, + connectingId, + allConnectors, + documentTypeCounts, + indexingConnectorIds, + logsSummary, + onConnectOAuth, + onConnectNonOAuth, + onCreateWebcrawler, + onCreateYouTubeCrawler, + onManage, + onViewAccountsList, }) => { - // Helper to find active task for a connector - const getActiveTaskForConnector = (connectorId: number): LogActiveTask | undefined => { - if (!logsSummary?.active_tasks) return undefined; - return logsSummary.active_tasks.find( - (task: LogActiveTask) => task.connector_id === connectorId - ); - }; + // Helper to find active task for a connector + const getActiveTaskForConnector = ( + connectorId: number + ): LogActiveTask | undefined => { + if (!logsSummary?.active_tasks) return undefined; + return logsSummary.active_tasks.find( + (task: LogActiveTask) => task.connector_id === connectorId + ); + }; - // Filter connectors based on search - const filteredOAuth = OAUTH_CONNECTORS.filter( - (c) => - c.title.toLowerCase().includes(searchQuery.toLowerCase()) || - c.description.toLowerCase().includes(searchQuery.toLowerCase()) - ); + // Filter connectors based on search + const filteredOAuth = OAUTH_CONNECTORS.filter( + (c) => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); - const filteredCrawlers = CRAWLERS.filter( - (c) => - c.title.toLowerCase().includes(searchQuery.toLowerCase()) || - c.description.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredCrawlers = CRAWLERS.filter( + (c) => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); - const filteredOther = OTHER_CONNECTORS.filter( - (c) => - c.title.toLowerCase().includes(searchQuery.toLowerCase()) || - c.description.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const filteredOther = OTHER_CONNECTORS.filter( + (c) => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.description.toLowerCase().includes(searchQuery.toLowerCase()) + ); - return ( -
- {/* Per-Type OAuth Connector Groups */} - {filteredOAuth.map((connectorType) => { - const userConnectors = - allConnectors?.filter( - (c: SearchSourceConnector) => c.connector_type === connectorType.connectorType - ) || []; - const isConnecting = connectingId === connectorType.id; + return ( +
+ {/* Quick Connect */} + {filteredOAuth.length > 0 && ( +
+
+

+ Quick Connect +

+
+
+ {filteredOAuth.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + const isConnecting = connectingId === connector.id; + // Find the actual connector object if connected + const actualConnector = + isConnected && allConnectors + ? allConnectors.find( + (c: SearchSourceConnector) => + c.connector_type === connector.connectorType + ) + : undefined; - return ( -
- {/* Group Header */} -
-

- {connectorType.title} Integrations -

- {userConnectors.length > 0 && ( - - )} -
+ const documentCount = getDocumentCountForConnector( + connector.connectorType, + documentTypeCounts + ); + const isIndexing = + actualConnector && + indexingConnectorIds?.has(actualConnector.id); + const activeTask = actualConnector + ? getActiveTaskForConnector(actualConnector.id) + : undefined; -
- {userConnectors.length === 0 ? ( - onConnectOAuth(connectorType)} - /> - ) : ( - userConnectors.map((connector: SearchSourceConnector) => { - const documentCount = getDocumentCountForConnector( - connector.connector_type, - documentTypeCounts - ); - const isIndexing = indexingConnectorIds?.has(connector.id); - const activeTask = getActiveTaskForConnector(connector.id); + return ( + onConnectOAuth(connector)} + onManage={ + isConnected && onViewAccountsList + ? () => onViewAccountsList(connector) + : undefined + } + /> + ); + })} +
+
+ )} - return ( - onConnectOAuth(connectorType)} - onManage={onManage ? () => onManage(connector) : undefined} - /> - ); - }) - )} -
-
- ); - })} + {/* More Integrations */} + {filteredOther.length > 0 && ( +
+
+

+ More Integrations +

+
+
+ {filteredOther.map((connector) => { + const isConnected = connectedTypes.has(connector.connectorType); + const isConnecting = connectingId === connector.id; - {/* More Integrations */} - {filteredOther.length > 0 && ( -
-
-

More Integrations

-
-
- {filteredOther.map((connector) => { - const isConnected = connectedTypes.has(connector.connectorType); - const isConnecting = connectingId === connector.id; + // Find the actual connector object if connected + const actualConnector = + isConnected && allConnectors + ? allConnectors.find( + (c: SearchSourceConnector) => + c.connector_type === connector.connectorType + ) + : undefined; - // Find the actual connector object if connected - const actualConnector = - isConnected && allConnectors - ? allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === connector.connectorType - ) - : undefined; + const documentCount = getDocumentCountForConnector( + connector.connectorType, + documentTypeCounts + ); + const isIndexing = + actualConnector && + indexingConnectorIds?.has(actualConnector.id); + const activeTask = actualConnector + ? getActiveTaskForConnector(actualConnector.id) + : undefined; - const documentCount = getDocumentCountForConnector( - connector.connectorType, - documentTypeCounts - ); - const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id); - const activeTask = actualConnector - ? getActiveTaskForConnector(actualConnector.id) - : undefined; + const handleConnect = onConnectNonOAuth + ? () => onConnectNonOAuth(connector.connectorType) + : () => {}; // Fallback - connector popup should handle all connector types - const handleConnect = onConnectNonOAuth - ? () => onConnectNonOAuth(connector.connectorType) - : () => {}; // Fallback - connector popup should handle all connector types + return ( + onManage(actualConnector) + : undefined + } + /> + ); + })} +
+
+ )} - return ( - onManage(actualConnector) : undefined - } - /> - ); - })} -
-
- )} + {/* Content Sources */} + {filteredCrawlers.length > 0 && ( +
+
+

+ Content Sources +

+
+
+ {filteredCrawlers.map((crawler) => { + const isYouTube = crawler.id === "youtube-crawler"; + const isWebcrawler = crawler.id === "webcrawler-connector"; - {/* Content Sources */} - {filteredCrawlers.length > 0 && ( -
-
-

Content Sources

-
-
- {filteredCrawlers.map((crawler) => { - const isYouTube = crawler.id === "youtube-crawler"; - const isWebcrawler = crawler.id === "webcrawler-connector"; + // For crawlers that are actual connectors, check connection status + const isConnected = crawler.connectorType + ? connectedTypes.has(crawler.connectorType) + : false; + const isConnecting = connectingId === crawler.id; - // For crawlers that are actual connectors, check connection status - const isConnected = crawler.connectorType - ? connectedTypes.has(crawler.connectorType) - : false; - const isConnecting = connectingId === crawler.id; + // Find the actual connector object if connected + const actualConnector = + isConnected && crawler.connectorType && allConnectors + ? allConnectors.find( + (c: SearchSourceConnector) => + c.connector_type === crawler.connectorType + ) + : undefined; - // Find the actual connector object if connected - const actualConnector = - isConnected && crawler.connectorType && allConnectors - ? allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === crawler.connectorType - ) - : undefined; + const documentCount = crawler.connectorType + ? getDocumentCountForConnector( + crawler.connectorType, + documentTypeCounts + ) + : undefined; + const isIndexing = + actualConnector && + indexingConnectorIds?.has(actualConnector.id); + const activeTask = actualConnector + ? getActiveTaskForConnector(actualConnector.id) + : undefined; - const documentCount = crawler.connectorType - ? getDocumentCountForConnector(crawler.connectorType, documentTypeCounts) - : undefined; - const isIndexing = actualConnector && indexingConnectorIds?.has(actualConnector.id); - const activeTask = actualConnector - ? getActiveTaskForConnector(actualConnector.id) - : undefined; + const handleConnect = + isYouTube && onCreateYouTubeCrawler + ? onCreateYouTubeCrawler + : isWebcrawler && onCreateWebcrawler + ? onCreateWebcrawler + : crawler.connectorType && onConnectNonOAuth + ? () => { + if (crawler.connectorType) { + onConnectNonOAuth(crawler.connectorType); + } + } + : () => {}; // Fallback for non-connector crawlers - const handleConnect = - isYouTube && onCreateYouTubeCrawler - ? onCreateYouTubeCrawler - : isWebcrawler && onCreateWebcrawler - ? onCreateWebcrawler - : crawler.connectorType && onConnectNonOAuth - ? () => { - if (crawler.connectorType) { - onConnectNonOAuth(crawler.connectorType); - } - } - : () => {}; // Fallback for non-connector crawlers - - return ( - onManage(actualConnector) : undefined - } - /> - ); - })} -
-
- )} -
- ); + return ( + onManage(actualConnector) + : undefined + } + /> + ); + })} +
+ + )} +
+ ); }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx new file mode 100644 index 000000000..23faedc4a --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns"; +import { ArrowLeft, Loader2, Plus } from "lucide-react"; +import type { FC } from "react"; +import { Button } from "@/components/ui/button"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; +import { cn } from "@/lib/utils"; +import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; +import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; + +interface ConnectorAccountsListViewProps { + connectorType: string; + connectorTitle: string; + connectors: SearchSourceConnector[]; + indexingConnectorIds: Set; + logsSummary: LogSummary | undefined; + documentTypeCounts?: Record; + onBack: () => void; + onManage: (connector: SearchSourceConnector) => void; + onAddAccount: () => void; + isConnecting?: boolean; +} + +/** + * Format document count (e.g., "1.2k docs", "500 docs", "1.5M docs") + */ +function formatDocumentCount(count: number | undefined): string { + if (count === undefined || count === 0) return "0 docs"; + if (count < 1000) return `${count} docs`; + if (count < 1000000) { + const k = (count / 1000).toFixed(1); + return `${k.replace(/\.0$/, "")}k docs`; + } + const m = (count / 1000000).toFixed(1); + return `${m.replace(/\.0$/, "")}M docs`; +} + +/** + * Format last indexed date with contextual messages + */ +function formatLastIndexedDate(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const minutesAgo = differenceInMinutes(now, date); + const daysAgo = differenceInDays(now, date); + + if (minutesAgo < 1) { + return "Just now"; + } + + if (minutesAgo < 60) { + return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`; + } + + if (isToday(date)) { + return `Today at ${format(date, "h:mm a")}`; + } + + if (isYesterday(date)) { + return `Yesterday at ${format(date, "h:mm a")}`; + } + + if (daysAgo < 7) { + return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`; + } + + return format(date, "MMM d, yyyy"); +} + +export const ConnectorAccountsListView: FC = ({ + connectorType, + connectorTitle, + connectors, + indexingConnectorIds, + logsSummary, + documentTypeCounts, + onBack, + onManage, + onAddAccount, + isConnecting = false, +}) => { + // Filter connectors to only show those of this type + const typeConnectors = connectors.filter((c) => c.connector_type === connectorType); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+ {getConnectorIcon(connectorType, "size-5")} +
+
+

{connectorTitle} Accounts

+

+ {typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""} +

+
+
+
+ {/* Add Account Button with dashed border */} + +
+
+ + {/* Content */} +
+ {/* Connected Accounts Grid */} +
+ {typeConnectors.map((connector) => { + const isIndexing = indexingConnectorIds.has(connector.id); + const activeTask = logsSummary?.active_tasks?.find( + (task: LogActiveTask) => task.connector_id === connector.id + ); + const documentCount = getDocumentCountForConnector( + connector.connector_type, + documentTypeCounts + ); + + return ( +
+
+ {getConnectorIcon(connector.connector_type, "size-6")} +
+
+

+ {getConnectorDisplayName(connector.name)} +

+ {isIndexing ? ( +

+ + Indexing... + {activeTask?.message && ( + + • {activeTask.message} + + )} +

+ ) : ( +

+ {connector.last_indexed_at + ? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}` + : "Never indexed"} +

+ )} +

+ {formatDocumentCount(documentCount)} +

+
+ +
+ ); + })} +
+
+
+ ); +}; + From 9ad1348d6b1c48da49f0780eb181925d5a1a525b Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 10:54:49 +0200 Subject: [PATCH 17/24] feat: add connectorId support for multi-account OAuth connectors Backend: - Add connectorId to OAuth redirect URLs in all 10 connector routes - Enables frontend to identify the specific connector created Frontend: - Update OAuth success handler to use connectorId for finding new connector - Set connectorId in URL when transitioning to configure view - Add connectorId support in URL sync effect for page refresh - Consolidate handleAddAccountOAuth into handleConnectOAuth - Update indexing config view to show connector type and display name --- .../routes/airtable_add_connector_route.py | 2 +- .../routes/confluence_add_connector_route.py | 2 +- .../app/routes/discord_add_connector_route.py | 2 +- .../google_calendar_add_connector_route.py | 2 +- .../google_drive_add_connector_route.py | 2 +- .../google_gmail_add_connector_route.py | 2 +- .../app/routes/jira_add_connector_route.py | 2 +- .../app/routes/linear_add_connector_route.py | 2 +- .../app/routes/notion_add_connector_route.py | 2 +- .../app/routes/slack_add_connector_route.py | 2 +- .../assistant-ui/connector-popup.tsx | 3 +- .../views/indexing-configuration-view.tsx | 16 +++-- .../hooks/use-connector-dialog.ts | 71 +++++++------------ 13 files changed, 48 insertions(+), 62 deletions(-) diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py index 9632c9308..93a263ed0 100644 --- a/surfsense_backend/app/routes/airtable_add_connector_route.py +++ b/surfsense_backend/app/routes/airtable_add_connector_route.py @@ -332,7 +332,7 @@ async def airtable_callback( # Redirect to the frontend with success params for indexing config # Using query params to auto-open the popup with config view on new-chat page return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=airtable-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=airtable-connector&connectorId={new_connector.id}" ) except ValidationError as e: diff --git a/surfsense_backend/app/routes/confluence_add_connector_route.py b/surfsense_backend/app/routes/confluence_add_connector_route.py index 7c2a0e2ca..284b4768a 100644 --- a/surfsense_backend/app/routes/confluence_add_connector_route.py +++ b/surfsense_backend/app/routes/confluence_add_connector_route.py @@ -324,7 +324,7 @@ async def confluence_callback( # Redirect to the frontend with success params return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=confluence-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=confluence-connector&connectorId={new_connector.id}" ) except ValidationError as e: diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py index d32902730..0bd864b89 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -320,7 +320,7 @@ async def discord_callback( # Redirect to the frontend with success params return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=discord-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=discord-connector&connectorId={new_connector.id}" ) except ValidationError as e: diff --git a/surfsense_backend/app/routes/google_calendar_add_connector_route.py b/surfsense_backend/app/routes/google_calendar_add_connector_route.py index 7210efae0..0770ec030 100644 --- a/surfsense_backend/app/routes/google_calendar_add_connector_route.py +++ b/surfsense_backend/app/routes/google_calendar_add_connector_route.py @@ -218,7 +218,7 @@ async def calendar_callback( # Redirect to the frontend with success params for indexing config # Using query params to auto-open the popup with config view on new-chat page return RedirectResponse( - f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-calendar-connector" + f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-calendar-connector&connectorId={db_connector.id}" ) except ValidationError as e: await session.rollback() diff --git a/surfsense_backend/app/routes/google_drive_add_connector_route.py b/surfsense_backend/app/routes/google_drive_add_connector_route.py index e63e4df30..ba45d7a2f 100644 --- a/surfsense_backend/app/routes/google_drive_add_connector_route.py +++ b/surfsense_backend/app/routes/google_drive_add_connector_route.py @@ -297,7 +297,7 @@ async def drive_callback( ) return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-drive-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-drive-connector&connectorId={db_connector.id}" ) except HTTPException: diff --git a/surfsense_backend/app/routes/google_gmail_add_connector_route.py b/surfsense_backend/app/routes/google_gmail_add_connector_route.py index a6071ca53..6baeca83c 100644 --- a/surfsense_backend/app/routes/google_gmail_add_connector_route.py +++ b/surfsense_backend/app/routes/google_gmail_add_connector_route.py @@ -254,7 +254,7 @@ async def gmail_callback( # Redirect to the frontend with success params for indexing config # Using query params to auto-open the popup with config view on new-chat page return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-gmail-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-gmail-connector&connectorId={db_connector.id}" ) except IntegrityError as e: diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py index 4cb595058..e2eb20500 100644 --- a/surfsense_backend/app/routes/jira_add_connector_route.py +++ b/surfsense_backend/app/routes/jira_add_connector_route.py @@ -342,7 +342,7 @@ async def jira_callback( # Redirect to the frontend with success params return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=jira-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=jira-connector&connectorId={new_connector.id}" ) except ValidationError as e: diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py index 73bf500a3..f7a200322 100644 --- a/surfsense_backend/app/routes/linear_add_connector_route.py +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -293,7 +293,7 @@ async def linear_callback( # Redirect to the frontend with success params return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=linear-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=linear-connector&connectorId={new_connector.id}" ) except ValidationError as e: diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py index 251814d58..501c17e18 100644 --- a/surfsense_backend/app/routes/notion_add_connector_route.py +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -298,7 +298,7 @@ async def notion_callback( # Redirect to the frontend with success params return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=notion-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=notion-connector&connectorId={new_connector.id}" ) except ValidationError as e: diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py index 50c505a78..4917dae6d 100644 --- a/surfsense_backend/app/routes/slack_add_connector_route.py +++ b/surfsense_backend/app/routes/slack_add_connector_route.py @@ -309,7 +309,7 @@ async def slack_callback( # Redirect to the frontend with success params return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=slack-connector" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=slack-connector&connectorId={new_connector.id}" ) except ValidationError as e: diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index c5e996c4c..cb98d3731 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -86,7 +86,6 @@ export const ConnectorIndicator: FC = () => { handleBackFromYouTube, handleViewAccountsList, handleBackFromAccountsList, - handleAddAccountOAuth, handleQuickIndexConnector, connectorConfig, setConnectorConfig, @@ -214,7 +213,7 @@ export const ConnectorIndicator: FC = () => { (c) => c.connectorType === viewingAccountsType.connectorType ); if (oauthConnector) { - handleAddAccountOAuth(oauthConnector); + handleConnectOAuth(oauthConnector); } }} isConnecting={connectingId !== null} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx index d479dda8d..2dcadf459 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx @@ -8,8 +8,10 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { cn } from "@/lib/utils"; import { DateRangeSelector } from "../../components/date-range-selector"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; -import type { IndexingConfigState } from "../../constants/connector-constants"; +import { OAUTH_CONNECTORS, type IndexingConfigState } from "../../constants/connector-constants"; import { getConnectorConfigComponent } from "../index"; +import { getConnectorTypeDisplay } from "@/lib/connectors/utils"; +import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; interface IndexingConfigurationViewProps { config: IndexingConfigState; @@ -89,12 +91,14 @@ export const IndexingConfigurationView: FC = ({ }; }, [checkScrollState]); + const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type); + return (
{/* Fixed Header */}
@@ -111,14 +115,14 @@ export const IndexingConfigurationView: FC = ({ )} {/* Success header */} -
+
-

- {config.connectorTitle} Connected! -

+
+ {getConnectorTypeDisplay(connector?.connector_type || "")} Connected ! {getConnectorDisplayName(connector?.name || "")} +

Configure when to start syncing your data

diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index a9d4871e1..53774b76d 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -148,14 +148,22 @@ export const useConnectorDialog = () => { // YouTube view is active - no additional state needed } - if (params.view === "configure" && params.connector && !indexingConfig) { + // Handle configure view (for page refresh support) + if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) { const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector); - if (oauthConnector && allConnectors) { - const existingConnector = allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); + if (oauthConnector) { + let existingConnector: SearchSourceConnector | undefined; + if (params.connectorId) { + const connectorId = parseInt(params.connectorId, 10); + existingConnector = allConnectors.find( + (c: SearchSourceConnector) => c.id === connectorId + ); + } else { + existingConnector = allConnectors.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + } if (existingConnector) { - // Validate connector data before setting state const connectorValidation = searchSourceConnector.safeParse(existingConnector); if (connectorValidation.success) { const config = validateIndexingConfigState({ @@ -253,11 +261,19 @@ export const useConnectorDialog = () => { refetchAllConnectors().then((result) => { if (!result.data) return; - const newConnector = result.data.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); + let newConnector: SearchSourceConnector | undefined; + if (params.connectorId) { + const connectorId = parseInt(params.connectorId, 10); + newConnector = result.data.find( + (c: SearchSourceConnector) => c.id === connectorId + ); + } else { + newConnector = result.data.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + } + if (newConnector) { - // Validate connector data before setting state const connectorValidation = searchSourceConnector.safeParse(newConnector); if (connectorValidation.success) { const config = validateIndexingConfigState({ @@ -271,6 +287,7 @@ export const useConnectorDialog = () => { setIsOpen(true); const url = new URL(window.location.href); url.searchParams.delete("success"); + url.searchParams.set("connectorId", newConnector.id.toString()); url.searchParams.set("view", "configure"); window.history.replaceState({}, "", url.toString()); } else { @@ -691,39 +708,6 @@ export const useConnectorDialog = () => { router.replace(url.pathname + url.search, { scroll: false }); }, [router]); - // Handle adding a new account for OAuth connector (from accounts list view) - const handleAddAccountOAuth = useCallback( - async (connector: (typeof OAUTH_CONNECTORS)[number]) => { - if (!searchSpaceId || !connector.authEndpoint) return; - - // Set connecting state - setConnectingId(connector.id); - - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, - { method: "GET" } - ); - - if (!response.ok) { - throw new Error(`Failed to initiate ${connector.title} OAuth`); - } - - const data = await response.json(); - const validatedData = parseOAuthAuthResponse(data); - window.location.href = validatedData.auth_url; - } catch (error) { - console.error(`Error connecting to ${connector.title}:`, error); - if (error instanceof Error && error.message.includes("Invalid auth URL")) { - toast.error(`Invalid response from ${connector.title} OAuth endpoint`); - } else { - toast.error(`Failed to connect to ${connector.title}`); - } - setConnectingId(null); - } - }, - [searchSpaceId] - ); // Handle starting indexing const handleStartIndexing = useCallback( @@ -1249,7 +1233,6 @@ export const useConnectorDialog = () => { handleBackFromYouTube, handleViewAccountsList, handleBackFromAccountsList, - handleAddAccountOAuth, handleQuickIndexConnector, connectorConfig, setConnectorConfig, From 3ff87a218dbf3df40503190d3980eafb800ca39f Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 11:40:21 +0200 Subject: [PATCH 18/24] feat: improve connector popup with grouped OAuth connectors Active Connectors tab: - Group OAuth connectors by type (Gmail, Google Drive, etc.) - Show account count badge on grouped cards - Show most recent last indexed date across all accounts - Show non-OAuth connectors individually with active task messages All Connectors tab: - Show most recent last indexed date for OAuth connector types - Check if any account is indexing for OAuth types Accounts List View: - Remove document count from individual account cards - Back button returns to previous tab (not always All Connectors) General: - Update handleViewAccountsList to use (connectorType, connectorTitle) signature - Consistent behavior for viewing accounts from both tabs --- .../assistant-ui/connector-popup.tsx | 26 +-- .../hooks/use-connector-dialog.ts | 13 +- .../tabs/active-connectors-tab.tsx | 181 ++++++++++++++---- .../tabs/all-connectors-tab.tsx | 41 ++-- .../views/connector-accounts-list-view.tsx | 24 --- surfsense_web/lib/connectors/utils.ts | 1 + 6 files changed, 193 insertions(+), 93 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index cb98d3731..0f8d341c2 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -205,7 +205,6 @@ export const ConnectorIndicator: FC = () => { connectors={(allConnectors || []) as SearchSourceConnector[]} indexingConnectorIds={indexingConnectorIds} logsSummary={logsSummary} - documentTypeCounts={documentTypeCounts} onBack={handleBackFromAccountsList} onManage={handleStartEdit} onAddAccount={() => { @@ -317,18 +316,19 @@ export const ConnectorIndicator: FC = () => { /> - +
{/* Bottom fade shadow */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 53774b76d..3ab65dd89 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -679,19 +679,20 @@ export const useConnectorDialog = () => { // Handle viewing accounts list for OAuth connector type const handleViewAccountsList = useCallback( - (connector: (typeof OAUTH_CONNECTORS)[number]) => { + (connectorType: string, connectorTitle: string) => { if (!searchSpaceId) return; setViewingAccountsType({ - connectorType: connector.connectorType, - connectorTitle: connector.title, + connectorType, + connectorTitle, }); - // Update URL to show accounts view + // Update URL to show accounts view, preserving current tab const url = new URL(window.location.href); url.searchParams.set("modal", "connectors"); url.searchParams.set("view", "accounts"); - url.searchParams.set("connectorType", connector.connectorType); + url.searchParams.set("connectorType", connectorType); + // Keep the current tab in URL so we can go back to it window.history.pushState({ modal: true }, "", url.toString()); }, [searchSpaceId] @@ -702,7 +703,7 @@ export const useConnectorDialog = () => { setViewingAccountsType(null); const url = new URL(window.location.href); url.searchParams.set("modal", "connectors"); - url.searchParams.set("tab", "all"); + // Keep the current tab (don't change it) - just remove view-specific params url.searchParams.delete("view"); url.searchParams.delete("connectorType"); router.replace(url.pathname + url.search, { scroll: false }); diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index 04e819bc8..d2f8a7fa6 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -11,8 +11,8 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; +import { OAUTH_CONNECTORS } from "../constants/connector-constants"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; -import { getConnectorDisplayName } from "./all-connectors-tab"; interface ActiveConnectorsTabProps { searchQuery: string; @@ -25,6 +25,7 @@ interface ActiveConnectorsTabProps { searchSpaceId: string; onTabChange: (value: string) => void; onManage?: (connector: SearchSourceConnector) => void; + onViewAccountsList?: (connectorType: string, connectorTitle: string) => void; } export const ActiveConnectorsTab: FC = ({ @@ -37,6 +38,7 @@ export const ActiveConnectorsTab: FC = ({ searchSpaceId, onTabChange, onManage, + onViewAccountsList, }) => { const router = useRouter(); @@ -72,38 +74,24 @@ export const ActiveConnectorsTab: FC = ({ const minutesAgo = differenceInMinutes(now, date); const daysAgo = differenceInDays(now, date); - // Just now (within last minute) - if (minutesAgo < 1) { - return "Just now"; - } - - // X minutes ago (less than 1 hour) - if (minutesAgo < 60) { - return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`; - } - - // Today at [time] - if (isToday(date)) { - return `Today at ${format(date, "h:mm a")}`; - } - - // Yesterday at [time] - if (isYesterday(date)) { - return `Yesterday at ${format(date, "h:mm a")}`; - } - - // X days ago (less than 7 days) - if (daysAgo < 7) { - return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`; - } - - // Full date for older entries + if (minutesAgo < 1) return "Just now"; + if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`; + if (isToday(date)) return `Today at ${format(date, "h:mm a")}`; + if (isYesterday(date)) return `Yesterday at ${format(date, "h:mm a")}`; + if (daysAgo < 7) return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`; return format(date, "MMM d, yyyy"); }; - // Document types that should be shown as cards (not from connectors) - // These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes), - // YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR) + // Get most recent last indexed date from a list of connectors + const getMostRecentLastIndexed = (connectorsList: SearchSourceConnector[]): string | undefined => { + return connectorsList.reduce((latest, c) => { + if (!c.last_indexed_at) return latest; + if (!latest) return c.last_indexed_at; + return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest; + }, undefined); + }; + + // Document types that should be shown as standalone cards (not from connectors) const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"]; // Filter to only show standalone document types that have documents (count > 0) @@ -119,8 +107,47 @@ export const ActiveConnectorsTab: FC = ({ return doc.label.toLowerCase().includes(searchQuery.toLowerCase()); }); - // Filter connectors based on search query - const filteredConnectors = connectors.filter((connector) => { + // Get OAuth connector types set for quick lookup + const oauthConnectorTypes = new Set(OAUTH_CONNECTORS.map((c) => c.connectorType)); + + // Separate OAuth and non-OAuth connectors + const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type)); + const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type)); + + // Group OAuth connectors by type + const oauthConnectorsByType = oauthConnectors.reduce( + (acc, connector) => { + const type = connector.connector_type; + if (!acc[type]) { + acc[type] = []; + } + acc[type].push(connector); + return acc; + }, + {} as Record + ); + + // Get display info for OAuth connector type + const getOAuthConnectorTypeInfo = (connectorType: string) => { + const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType); + return { + title: oauthConnector?.title || connectorType.replace(/_/g, " ").replace(/connector/gi, "").trim(), + }; + }; + + // Filter OAuth connector types based on search query + const filteredOAuthConnectorTypes = Object.entries(oauthConnectorsByType).filter(([connectorType]) => { + if (!searchQuery) return true; + const searchLower = searchQuery.toLowerCase(); + const { title } = getOAuthConnectorTypeInfo(connectorType); + return ( + title.toLowerCase().includes(searchLower) || + connectorType.toLowerCase().includes(searchLower) + ); + }); + + // Filter non-OAuth connectors based on search query + const filteredNonOAuthConnectors = nonOauthConnectors.filter((connector) => { if (!searchQuery) return true; const searchLower = searchQuery.toLowerCase(); return ( @@ -129,18 +156,98 @@ export const ActiveConnectorsTab: FC = ({ ); }); + const hasActiveConnectors = filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0; + return ( {hasSources ? (
{/* Active Connectors Section */} - {filteredConnectors.length > 0 && ( + {hasActiveConnectors && (

Active Connectors

- {filteredConnectors.map((connector) => { + {/* OAuth Connectors - Grouped by Type */} + {filteredOAuthConnectorTypes.map(([connectorType, typeConnectors]) => { + const { title } = getOAuthConnectorTypeInfo(connectorType); + const isAnyIndexing = typeConnectors.some( + (c: SearchSourceConnector) => indexingConnectorIds.has(c.id) + ); + const documentCount = getDocumentCountForConnector( + connectorType, + documentTypeCounts + ); + const accountCount = typeConnectors.length; + const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors); + + const handleManageClick = () => { + if (onViewAccountsList) { + onViewAccountsList(connectorType, title); + } else if (onManage && typeConnectors[0]) { + onManage(typeConnectors[0]); + } + }; + + return ( +
+ {/* Account count badge */} +
+ {accountCount > 99 ? "99+" : accountCount} {accountCount === 1 ? "Account" : "Accounts"} +
+
+ {getConnectorIcon(connectorType, "size-6")} +
+
+

+ {title} +

+ {isAnyIndexing ? ( +

+ + Indexing... +

+ ) : ( +

+ {mostRecentLastIndexed + ? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}` + : "Never indexed"} +

+ )} +

+ {formatDocumentCount(documentCount)} +

+
+ +
+ ); + })} + + {/* Non-OAuth Connectors - Individual Cards */} + {filteredNonOAuthConnectors.map((connector) => { const isIndexing = indexingConnectorIds.has(connector.id); const activeTask = logsSummary?.active_tasks?.find( (task: LogActiveTask) => task.connector_id === connector.id @@ -162,7 +269,7 @@ export const ActiveConnectorsTab: FC = ({ >
= ({

- {getConnectorDisplayName(connector.name)} + {connector.name}

{isIndexing ? (

@@ -198,7 +305,7 @@ export const ActiveConnectorsTab: FC = ({

{getStatusContent()}
{isConnected && documentCount !== undefined && ( -

- {formatDocumentCount(documentCount)} +

+ {formatDocumentCount(documentCount)} + {accountCount !== undefined && accountCount > 0 && ( + <> + + {accountCount} {accountCount === 1 ? "Account" : "Accounts"} + + )}

)}
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index d2f8a7fa6..2f0e31106 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -200,10 +200,6 @@ export const ActiveConnectorsTab: FC = ({ : "bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10" )} > - {/* Account count badge */} -
- {accountCount > 99 ? "99+" : accountCount} {accountCount === 1 ? "Account" : "Accounts"} -
= ({ : "Never indexed"}

)} -

- {formatDocumentCount(documentCount)} +

+ {formatDocumentCount(documentCount)} + + {accountCount} {accountCount === 1 ? "Account" : "Accounts"}

{/* Bottom fade shadow */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx index 3b1c41cdf..e8fe6da33 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx @@ -161,7 +161,9 @@ export const ConnectorCard: FC = ({ {accountCount !== undefined && accountCount > 0 && ( <> - {accountCount} {accountCount === 1 ? "Account" : "Accounts"} + + {accountCount} {accountCount === 1 ? "Account" : "Accounts"} + )}

diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index e09bdea90..bdfe9af77 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -148,7 +148,9 @@ export const ConnectorEditView: FC = ({ {getConnectorIcon(connector.connector_type, "size-7")}
-

{connector.name}

+

+ {connector.name} +

Manage your connector settings and sync configuration

diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx index 2dcadf459..8f4a29e61 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx @@ -1,17 +1,17 @@ "use client"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; -import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "next/navigation"; +import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { getConnectorTypeDisplay } from "@/lib/connectors/utils"; import { cn } from "@/lib/utils"; import { DateRangeSelector } from "../../components/date-range-selector"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; -import { OAUTH_CONNECTORS, type IndexingConfigState } from "../../constants/connector-constants"; -import { getConnectorConfigComponent } from "../index"; -import { getConnectorTypeDisplay } from "@/lib/connectors/utils"; +import { type IndexingConfigState, OAUTH_CONNECTORS } from "../../constants/connector-constants"; import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; +import { getConnectorConfigComponent } from "../index"; interface IndexingConfigurationViewProps { config: IndexingConfigState; @@ -121,7 +121,12 @@ export const IndexingConfigurationView: FC = ({
- {getConnectorTypeDisplay(connector?.connector_type || "")} Connected ! {getConnectorDisplayName(connector?.name || "")} + + {getConnectorTypeDisplay(connector?.connector_type || "")} Connected ! + {" "} + + {getConnectorDisplayName(connector?.name || "")} +

Configure when to start syncing your data diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 1bfef9c43..2c8248255 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -243,7 +243,14 @@ export const useConnectorDialog = () => { console.warn("Invalid connector popup query params:", error); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType, viewingAccountsType]); + }, [ + searchParams, + allConnectors, + editingConnector, + indexingConfig, + connectingConnectorType, + viewingAccountsType, + ]); // Detect OAuth success / Failure and transition to config view useEffect(() => { @@ -292,9 +299,7 @@ export const useConnectorDialog = () => { let newConnector: SearchSourceConnector | undefined; if (params.connectorId) { const connectorId = parseInt(params.connectorId, 10); - newConnector = result.data.find( - (c: SearchSourceConnector) => c.id === connectorId - ); + newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId); } else { newConnector = result.data.find( (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType @@ -737,7 +742,6 @@ export const useConnectorDialog = () => { router.replace(url.pathname + url.search, { scroll: false }); }, [router]); - // Handle starting indexing const handleStartIndexing = useCallback( async (refreshConnectors: () => void) => { diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index 2f0e31106..7f1bd28f0 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -83,7 +83,9 @@ export const ActiveConnectorsTab: FC = ({ }; // Get most recent last indexed date from a list of connectors - const getMostRecentLastIndexed = (connectorsList: SearchSourceConnector[]): string | undefined => { + const getMostRecentLastIndexed = ( + connectorsList: SearchSourceConnector[] + ): string | undefined => { return connectorsList.reduce((latest, c) => { if (!c.last_indexed_at) return latest; if (!latest) return c.last_indexed_at; @@ -131,20 +133,27 @@ export const ActiveConnectorsTab: FC = ({ const getOAuthConnectorTypeInfo = (connectorType: string) => { const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType); return { - title: oauthConnector?.title || connectorType.replace(/_/g, " ").replace(/connector/gi, "").trim(), + title: + oauthConnector?.title || + connectorType + .replace(/_/g, " ") + .replace(/connector/gi, "") + .trim(), }; }; // Filter OAuth connector types based on search query - const filteredOAuthConnectorTypes = Object.entries(oauthConnectorsByType).filter(([connectorType]) => { - if (!searchQuery) return true; - const searchLower = searchQuery.toLowerCase(); - const { title } = getOAuthConnectorTypeInfo(connectorType); - return ( - title.toLowerCase().includes(searchLower) || - connectorType.toLowerCase().includes(searchLower) - ); - }); + const filteredOAuthConnectorTypes = Object.entries(oauthConnectorsByType).filter( + ([connectorType]) => { + if (!searchQuery) return true; + const searchLower = searchQuery.toLowerCase(); + const { title } = getOAuthConnectorTypeInfo(connectorType); + return ( + title.toLowerCase().includes(searchLower) || + connectorType.toLowerCase().includes(searchLower) + ); + } + ); // Filter non-OAuth connectors based on search query const filteredNonOAuthConnectors = nonOauthConnectors.filter((connector) => { @@ -156,7 +165,8 @@ export const ActiveConnectorsTab: FC = ({ ); }); - const hasActiveConnectors = filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0; + const hasActiveConnectors = + filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0; return ( @@ -172,8 +182,8 @@ export const ActiveConnectorsTab: FC = ({ {/* OAuth Connectors - Grouped by Type */} {filteredOAuthConnectorTypes.map(([connectorType, typeConnectors]) => { const { title } = getOAuthConnectorTypeInfo(connectorType); - const isAnyIndexing = typeConnectors.some( - (c: SearchSourceConnector) => indexingConnectorIds.has(c.id) + const isAnyIndexing = typeConnectors.some((c: SearchSourceConnector) => + indexingConnectorIds.has(c.id) ); const documentCount = getDocumentCountForConnector( connectorType, @@ -211,9 +221,7 @@ export const ActiveConnectorsTab: FC = ({ {getConnectorIcon(connectorType, "size-6")}

-

- {title} -

+

{title}

{isAnyIndexing ? (

@@ -229,7 +237,9 @@ export const ActiveConnectorsTab: FC = ({

{formatDocumentCount(documentCount)} - {accountCount} {accountCount === 1 ? "Account" : "Accounts"} + + {accountCount} {accountCount === 1 ? "Account" : "Accounts"} +

); }; - diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index da3b820e5..6ac1ec979 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -1,7 +1,7 @@ "use client"; -import { Upload } from "lucide-react"; import { useAtomValue } from "jotai"; +import { Upload } from "lucide-react"; import { useRouter } from "next/navigation"; import { createContext, diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index 407adba7a..93e3f26e1 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -1,5 +1,5 @@ import Image from "next/image"; -import { type StreamdownProps, Streamdown } from "streamdown"; +import { Streamdown, type StreamdownProps } from "streamdown"; import { cn } from "@/lib/utils"; interface MarkdownViewerProps { diff --git a/surfsense_web/content/docs/connectors/meta.json b/surfsense_web/content/docs/connectors/meta.json index 2515bc7d8..82e04e44f 100644 --- a/surfsense_web/content/docs/connectors/meta.json +++ b/surfsense_web/content/docs/connectors/meta.json @@ -20,4 +20,3 @@ ], "defaultOpen": true } - From 9841bdda7213eb0bafcf7a77a364ad38693d8cce Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Wed, 7 Jan 2026 15:27:54 +0200 Subject: [PATCH 24/24] style: format backend with ruff --- .../versions/57_allow_multiple_connectors_per_type.py | 8 +++++--- .../58_unique_connector_name_per_space_user.py | 8 +++++--- surfsense_backend/app/connectors/airtable_connector.py | 4 +++- .../app/routes/airtable_add_connector_route.py | 9 +++++---- .../app/routes/confluence_add_connector_route.py | 3 +-- .../app/routes/discord_add_connector_route.py | 3 +-- .../app/routes/google_calendar_add_connector_route.py | 6 ++++-- .../app/routes/google_drive_add_connector_route.py | 5 ++++- .../app/routes/google_gmail_add_connector_route.py | 6 ++++-- .../app/routes/jira_add_connector_route.py | 3 +-- .../app/routes/linear_add_connector_route.py | 10 ++++++---- .../app/routes/notion_add_connector_route.py | 3 +-- .../app/routes/slack_add_connector_route.py | 1 - surfsense_backend/app/utils/connector_naming.py | 9 ++++++--- 14 files changed, 46 insertions(+), 32 deletions(-) diff --git a/surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py b/surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py index bd2fccf72..25558f42e 100644 --- a/surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py +++ b/surfsense_backend/alembic/versions/57_allow_multiple_connectors_per_type.py @@ -7,6 +7,7 @@ Create Date: 2026-01-06 12:00:00.000000 """ from collections.abc import Sequence + from alembic import op # revision identifiers, used by Alembic. @@ -17,6 +18,7 @@ depends_on: str | Sequence[str] | None = None from sqlalchemy import text + def upgrade() -> None: connection = op.get_bind() constraint_exists = connection.execute( @@ -31,9 +33,10 @@ def upgrade() -> None: op.drop_constraint( "uq_searchspace_user_connector_type", "search_source_connectors", - type_="unique" + type_="unique", ) + def downgrade() -> None: connection = op.get_bind() constraint_exists = connection.execute( @@ -48,6 +51,5 @@ def downgrade() -> None: op.create_unique_constraint( "uq_searchspace_user_connector_type", "search_source_connectors", - ["search_space_id", "user_id", "connector_type"] + ["search_space_id", "user_id", "connector_type"], ) - diff --git a/surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py b/surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py index b840af267..7c35ab1d8 100644 --- a/surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py +++ b/surfsense_backend/alembic/versions/58_unique_connector_name_per_space_user.py @@ -8,6 +8,7 @@ Create Date: 2026-01-06 14:00:00.000000 """ from collections.abc import Sequence + from alembic import op revision: str = "58" @@ -17,6 +18,7 @@ depends_on: str | Sequence[str] | None = None from sqlalchemy import text + def upgrade() -> None: connection = op.get_bind() constraint_exists = connection.execute( @@ -31,9 +33,10 @@ def upgrade() -> None: op.create_unique_constraint( "uq_searchspace_user_connector_name", "search_source_connectors", - ["search_space_id", "user_id", "name"] + ["search_space_id", "user_id", "name"], ) + def downgrade() -> None: connection = op.get_bind() constraint_exists = connection.execute( @@ -48,6 +51,5 @@ def downgrade() -> None: op.drop_constraint( "uq_searchspace_user_connector_name", "search_source_connectors", - type_="unique" + type_="unique", ) - diff --git a/surfsense_backend/app/connectors/airtable_connector.py b/surfsense_backend/app/connectors/airtable_connector.py index 30a366cdd..8264f4bfa 100644 --- a/surfsense_backend/app/connectors/airtable_connector.py +++ b/surfsense_backend/app/connectors/airtable_connector.py @@ -414,7 +414,9 @@ async def fetch_airtable_user_email(access_token: str) -> str | None: logger.debug(f"Fetched Airtable user email: {email}") return email - logger.warning(f"Failed to fetch Airtable user info: {response.status_code}") + logger.warning( + f"Failed to fetch Airtable user info: {response.status_code}" + ) return None except Exception as e: diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py index 5fa8180bf..5efa63e59 100644 --- a/surfsense_backend/app/routes/airtable_add_connector_route.py +++ b/surfsense_backend/app/routes/airtable_add_connector_route.py @@ -11,19 +11,21 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config +from app.connectors.airtable_connector import fetch_airtable_user_email from app.db import ( SearchSourceConnector, SearchSourceConnectorType, User, get_async_session, ) -from app.connectors.airtable_connector import fetch_airtable_user_email from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase from app.users import current_active_user -from app.utils.connector_naming import check_duplicate_connector, generate_unique_connector_name +from app.utils.connector_naming import ( + check_duplicate_connector, + generate_unique_connector_name, +) from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) @@ -279,7 +281,6 @@ async def airtable_callback( user_email = await fetch_airtable_user_email(access_token) - # Calculate expiration time (UTC, tz-aware) expires_at = None if token_json.get("expires_in"): diff --git a/surfsense_backend/app/routes/confluence_add_connector_route.py b/surfsense_backend/app/routes/confluence_add_connector_route.py index 56abf62ce..6c5830b17 100644 --- a/surfsense_backend/app/routes/confluence_add_connector_route.py +++ b/surfsense_backend/app/routes/confluence_add_connector_route.py @@ -14,7 +14,6 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config from app.db import ( @@ -25,12 +24,12 @@ from app.db import ( ) from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.users import current_active_user -from app.utils.oauth_security import OAuthStateManager, TokenEncryption from app.utils.connector_naming import ( check_duplicate_connector, extract_identifier_from_credentials, generate_unique_connector_name, ) +from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py index 0bda191c6..1d8b40fcf 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -14,7 +14,6 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config from app.db import ( @@ -25,12 +24,12 @@ from app.db import ( ) from app.schemas.discord_auth_credentials import DiscordAuthCredentialsBase from app.users import current_active_user -from app.utils.oauth_security import OAuthStateManager, TokenEncryption from app.utils.connector_naming import ( check_duplicate_connector, extract_identifier_from_credentials, generate_unique_connector_name, ) +from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/routes/google_calendar_add_connector_route.py b/surfsense_backend/app/routes/google_calendar_add_connector_route.py index a1292fa43..08e5c2f04 100644 --- a/surfsense_backend/app/routes/google_calendar_add_connector_route.py +++ b/surfsense_backend/app/routes/google_calendar_add_connector_route.py @@ -12,7 +12,6 @@ from google_auth_oauthlib.flow import Flow from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config from app.connectors.google_gmail_connector import fetch_google_user_email @@ -23,7 +22,10 @@ from app.db import ( get_async_session, ) from app.users import current_active_user -from app.utils.connector_naming import check_duplicate_connector, generate_unique_connector_name +from app.utils.connector_naming import ( + check_duplicate_connector, + generate_unique_connector_name, +) from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/routes/google_drive_add_connector_route.py b/surfsense_backend/app/routes/google_drive_add_connector_route.py index 30a46a618..e15aed762 100644 --- a/surfsense_backend/app/routes/google_drive_add_connector_route.py +++ b/surfsense_backend/app/routes/google_drive_add_connector_route.py @@ -37,7 +37,10 @@ from app.db import ( get_async_session, ) from app.users import current_active_user -from app.utils.connector_naming import check_duplicate_connector, generate_unique_connector_name +from app.utils.connector_naming import ( + check_duplicate_connector, + generate_unique_connector_name, +) from app.utils.oauth_security import OAuthStateManager, TokenEncryption # Relax token scope validation for Google OAuth diff --git a/surfsense_backend/app/routes/google_gmail_add_connector_route.py b/surfsense_backend/app/routes/google_gmail_add_connector_route.py index 9919894f3..19fa019ce 100644 --- a/surfsense_backend/app/routes/google_gmail_add_connector_route.py +++ b/surfsense_backend/app/routes/google_gmail_add_connector_route.py @@ -12,7 +12,6 @@ from google_auth_oauthlib.flow import Flow from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config from app.connectors.google_gmail_connector import fetch_google_user_email @@ -23,7 +22,10 @@ from app.db import ( get_async_session, ) from app.users import current_active_user -from app.utils.connector_naming import check_duplicate_connector, generate_unique_connector_name +from app.utils.connector_naming import ( + check_duplicate_connector, + generate_unique_connector_name, +) from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py index 744cf7fd4..fb66f4da7 100644 --- a/surfsense_backend/app/routes/jira_add_connector_route.py +++ b/surfsense_backend/app/routes/jira_add_connector_route.py @@ -15,7 +15,6 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config from app.db import ( @@ -26,12 +25,12 @@ from app.db import ( ) from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase from app.users import current_active_user -from app.utils.oauth_security import OAuthStateManager, TokenEncryption from app.utils.connector_naming import ( check_duplicate_connector, extract_identifier_from_credentials, generate_unique_connector_name, ) +from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py index ce5cdbfb3..fc9501bfb 100644 --- a/surfsense_backend/app/routes/linear_add_connector_route.py +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -14,19 +14,21 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config +from app.connectors.linear_connector import fetch_linear_organization_name from app.db import ( SearchSourceConnector, SearchSourceConnectorType, User, get_async_session, ) -from app.connectors.linear_connector import fetch_linear_organization_name from app.schemas.linear_auth_credentials import LinearAuthCredentialsBase from app.users import current_active_user -from app.utils.connector_naming import check_duplicate_connector, generate_unique_connector_name +from app.utils.connector_naming import ( + check_duplicate_connector, + generate_unique_connector_name, +) from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) @@ -454,4 +456,4 @@ async def refresh_linear_token( logger.error(f"Failed to refresh Linear token: {e!s}", exc_info=True) raise HTTPException( status_code=500, detail=f"Failed to refresh Linear token: {e!s}" - ) from e \ No newline at end of file + ) from e diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py index c0331b4bc..aac821793 100644 --- a/surfsense_backend/app/routes/notion_add_connector_route.py +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -14,7 +14,6 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config from app.db import ( @@ -25,12 +24,12 @@ from app.db import ( ) from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase from app.users import current_active_user -from app.utils.oauth_security import OAuthStateManager, TokenEncryption from app.utils.connector_naming import ( check_duplicate_connector, extract_identifier_from_credentials, generate_unique_connector_name, ) +from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py index 6da7e5d24..62d2ccaaa 100644 --- a/surfsense_backend/app/routes/slack_add_connector_route.py +++ b/surfsense_backend/app/routes/slack_add_connector_route.py @@ -14,7 +14,6 @@ from fastapi.responses import RedirectResponse from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select from app.config import config from app.db import ( diff --git a/surfsense_backend/app/utils/connector_naming.py b/surfsense_backend/app/utils/connector_naming.py index 17b791c34..f9f1fdd21 100644 --- a/surfsense_backend/app/utils/connector_naming.py +++ b/surfsense_backend/app/utils/connector_naming.py @@ -31,7 +31,9 @@ BASE_NAME_FOR_TYPE = { def get_base_name_for_type(connector_type: SearchSourceConnectorType) -> str: """Get a friendly display name for a connector type.""" - return BASE_NAME_FOR_TYPE.get(connector_type, connector_type.replace("_", " ").title()) + return BASE_NAME_FOR_TYPE.get( + connector_type, connector_type.replace("_", " ").title() + ) def extract_identifier_from_credentials( @@ -178,9 +180,10 @@ async def generate_unique_connector_name( return f"{base} - {identifier}" # Fallback: use counter for uniqueness - count = await count_connectors_of_type(session, connector_type, search_space_id, user_id) + count = await count_connectors_of_type( + session, connector_type, search_space_id, user_id + ) if count == 0: return base return f"{base} ({count + 1})" -