diff --git a/surfsense_backend/app/routes/airtable_add_connector_route.py b/surfsense_backend/app/routes/airtable_add_connector_route.py index 93a263ed0..92fcbc67e 100644 --- a/surfsense_backend/app/routes/airtable_add_connector_route.py +++ b/surfsense_backend/app/routes/airtable_add_connector_route.py @@ -23,7 +23,7 @@ from app.db import ( 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.connector_naming import check_duplicate_connector, generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) @@ -303,6 +303,22 @@ async def airtable_callback( credentials_dict = credentials.to_dict() credentials_dict["_token_encrypted"] = True + # Check for duplicate connector (same account already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.AIRTABLE_CONNECTOR, + space_id, + user_id, + user_email, + ) + if is_duplicate: + logger.warning( + f"Duplicate Airtable connector detected for user {user_id} with email {user_email}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=airtable-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, diff --git a/surfsense_backend/app/routes/confluence_add_connector_route.py b/surfsense_backend/app/routes/confluence_add_connector_route.py index 284b4768a..56abf62ce 100644 --- a/surfsense_backend/app/routes/confluence_add_connector_route.py +++ b/surfsense_backend/app/routes/confluence_add_connector_route.py @@ -27,6 +27,7 @@ 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, ) @@ -296,6 +297,23 @@ async def confluence_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.CONFLUENCE_CONNECTOR, connector_config ) + + # Check for duplicate connector (same Confluence instance already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.CONFLUENCE_CONNECTOR, + space_id, + user_id, + connector_identifier, + ) + if is_duplicate: + logger.warning( + f"Duplicate Confluence connector detected for user {user_id} with instance {connector_identifier}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=confluence-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py index 0bd864b89..0bda191c6 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -27,6 +27,7 @@ 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, ) @@ -292,6 +293,23 @@ async def discord_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.DISCORD_CONNECTOR, connector_config ) + + # Check for duplicate connector (same server already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.DISCORD_CONNECTOR, + space_id, + user_id, + connector_identifier, + ) + if is_duplicate: + logger.warning( + f"Duplicate Discord connector detected for user {user_id} with server {connector_identifier}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=discord-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, 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 0770ec030..a721b62c1 100644 --- a/surfsense_backend/app/routes/google_calendar_add_connector_route.py +++ b/surfsense_backend/app/routes/google_calendar_add_connector_route.py @@ -23,7 +23,7 @@ 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.connector_naming import check_duplicate_connector, generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) @@ -195,6 +195,22 @@ async def calendar_callback( # Mark that credentials are encrypted for backward compatibility creds_dict["_token_encrypted"] = True + # Check for duplicate connector (same account already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + space_id, + user_id, + user_email, + ) + if is_duplicate: + logger.warning( + f"Duplicate Google Calendar connector detected for user {user_id} with email {user_email}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=google-calendar-connector" + ) + try: # Generate a unique, user-friendly connector name connector_name = await generate_unique_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 ba45d7a2f..1b02543d3 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,7 @@ 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.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 @@ -250,6 +250,22 @@ async def drive_callback( # Mark that credentials are encrypted for backward compatibility creds_dict["_token_encrypted"] = True + # Check for duplicate connector (same account already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + space_id, + user_id, + user_email, + ) + if is_duplicate: + logger.warning( + f"Duplicate Google Drive connector detected for user {user_id} with email {user_email}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=google-drive-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, 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 6baeca83c..4a7631919 100644 --- a/surfsense_backend/app/routes/google_gmail_add_connector_route.py +++ b/surfsense_backend/app/routes/google_gmail_add_connector_route.py @@ -23,7 +23,7 @@ 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.connector_naming import check_duplicate_connector, generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) @@ -226,6 +226,22 @@ async def gmail_callback( # Mark that credentials are encrypted for backward compatibility creds_dict["_token_encrypted"] = True + # Check for duplicate connector (same account already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR, + space_id, + user_id, + user_email, + ) + if is_duplicate: + logger.warning( + f"Duplicate Gmail connector detected for user {user_id} with email {user_email}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=google-gmail-connector" + ) + try: # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py index e2eb20500..744cf7fd4 100644 --- a/surfsense_backend/app/routes/jira_add_connector_route.py +++ b/surfsense_backend/app/routes/jira_add_connector_route.py @@ -28,6 +28,7 @@ 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, ) @@ -314,6 +315,23 @@ async def jira_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.JIRA_CONNECTOR, connector_config ) + + # Check for duplicate connector (same Jira instance already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.JIRA_CONNECTOR, + space_id, + user_id, + connector_identifier, + ) + if is_duplicate: + logger.warning( + f"Duplicate Jira connector detected for user {user_id} with instance {connector_identifier}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=jira-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py index f7a200322..ca1d09568 100644 --- a/surfsense_backend/app/routes/linear_add_connector_route.py +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -26,7 +26,7 @@ from app.db import ( 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.connector_naming import check_duplicate_connector, generate_unique_connector_name from app.utils.oauth_security import OAuthStateManager, TokenEncryption logger = logging.getLogger(__name__) @@ -265,6 +265,22 @@ async def linear_callback( "_token_encrypted": True, } + # Check for duplicate connector (same organization already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.LINEAR_CONNECTOR, + space_id, + user_id, + org_name, + ) + if is_duplicate: + logger.warning( + f"Duplicate Linear connector detected for user {user_id} with org {org_name}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=linear-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py index 501c17e18..c0331b4bc 100644 --- a/surfsense_backend/app/routes/notion_add_connector_route.py +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -27,6 +27,7 @@ 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, ) @@ -270,6 +271,23 @@ async def notion_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.NOTION_CONNECTOR, connector_config ) + + # Check for duplicate connector (same workspace already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.NOTION_CONNECTOR, + space_id, + user_id, + connector_identifier, + ) + if is_duplicate: + logger.warning( + f"Duplicate Notion connector detected for user {user_id} with workspace {connector_identifier}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=notion-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py index 4917dae6d..6da7e5d24 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.connector_naming import ( + check_duplicate_connector, extract_identifier_from_credentials, generate_unique_connector_name, ) @@ -280,6 +281,23 @@ async def slack_callback( connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.SLACK_CONNECTOR, connector_config ) + + # Check for duplicate connector (same workspace already connected) + is_duplicate = await check_duplicate_connector( + session, + SearchSourceConnectorType.SLACK_CONNECTOR, + space_id, + user_id, + connector_identifier, + ) + if is_duplicate: + logger.warning( + f"Duplicate Slack connector detected for user {user_id} with workspace {connector_identifier}" + ) + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=slack-connector" + ) + # Generate a unique, user-friendly connector name connector_name = await generate_unique_connector_name( session, diff --git a/surfsense_backend/app/utils/connector_naming.py b/surfsense_backend/app/utils/connector_naming.py index 6f582cd87..17b791c34 100644 --- a/surfsense_backend/app/utils/connector_naming.py +++ b/surfsense_backend/app/utils/connector_naming.py @@ -114,6 +114,41 @@ async def count_connectors_of_type( return result.scalar() or 0 +async def check_duplicate_connector( + session: AsyncSession, + connector_type: SearchSourceConnectorType, + search_space_id: int, + user_id: UUID, + identifier: str | None, +) -> bool: + """ + Check if a connector with the same identifier already exists. + + Args: + session: Database session + connector_type: The type of connector + search_space_id: The search space ID + user_id: The user ID + identifier: User identifier (email, workspace name, etc.) + + Returns: + True if a duplicate exists, False otherwise + """ + if not identifier: + return False + + expected_name = f"{get_base_name_for_type(connector_type)} - {identifier}" + 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, + SearchSourceConnector.name == expected_name, + ) + ) + return (result.scalar() or 0) > 0 + + async def generate_unique_connector_name( session: AsyncSession, connector_type: SearchSourceConnectorType, 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 808c7b428..a1b303163 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 @@ -12,6 +12,7 @@ export const connectorPopupQueryParamsSchema = z.object({ connectorId: z.string().optional(), connectorType: z.string().optional(), success: z.enum(["true", "false"]).optional(), + error: z.string().optional(), }); export type ConnectorPopupQueryParams = z.infer; 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 3ab65dd89..1bfef9c43 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 @@ -245,11 +245,39 @@ export const useConnectorDialog = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType, viewingAccountsType]); - // Detect OAuth success and transition to config view + // Detect OAuth success / Failure and transition to config view useEffect(() => { try { const params = parseConnectorPopupQueryParams(searchParams); + // Handle OAuth errors (e.g., duplicate account) + if (params.error && params.modal === "connectors") { + const oauthConnector = params.connector + ? OAUTH_CONNECTORS.find((c) => c.id === params.connector) + : null; + const connectorName = oauthConnector?.title || "connector"; + + if (params.error === "duplicate_account") { + toast.error(`This ${connectorName} account is already connected`, { + description: "Please use a different account or manage the existing connection.", + }); + } else { + toast.error(`Failed to connect ${connectorName}`, { + description: params.error.replace(/_/g, " "), + }); + } + + // Clean up error params from URL + const url = new URL(window.location.href); + url.searchParams.delete("error"); + url.searchParams.delete("connector"); + window.history.replaceState({}, "", url.toString()); + + // Open the popup to show the connectors + setIsOpen(true); + return; + } + if ( params.success === "true" && params.connector &&