diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index d2c667178..a2f662c23 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -50,6 +50,11 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret_here DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback DISCORD_BOT_TOKEN=your_bot_token_from_developer_portal +# Jira OAuth Configuration +JIRA_CLIENT_ID=our_jira_client_id +JIRA_CLIENT_SECRET=your_jira_client_secret +JIRA_REDIRECT_URI=http://localhost:8000/api/v1/auth/jira/connector/callback + # OAuth for Linear Connector LINEAR_CLIENT_ID=your_linear_client_id LINEAR_CLIENT_SECRET=your_linear_client_secret diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index f65a94cc0..f227f3131 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -95,6 +95,12 @@ class Config: NOTION_CLIENT_SECRET = os.getenv("NOTION_CLIENT_SECRET") NOTION_REDIRECT_URI = os.getenv("NOTION_REDIRECT_URI") + # Atlassian OAuth (shared for Jira and Confluence) + ATLASSIAN_CLIENT_ID = os.getenv("ATLASSIAN_CLIENT_ID") + ATLASSIAN_CLIENT_SECRET = os.getenv("ATLASSIAN_CLIENT_SECRET") + JIRA_REDIRECT_URI = os.getenv("JIRA_REDIRECT_URI") + CONFLUENCE_REDIRECT_URI = os.getenv("CONFLUENCE_REDIRECT_URI") + # Linear OAuth LINEAR_CLIENT_ID = os.getenv("LINEAR_CLIENT_ID") LINEAR_CLIENT_SECRET = os.getenv("LINEAR_CLIENT_SECRET") diff --git a/surfsense_backend/app/connectors/confluence_history.py b/surfsense_backend/app/connectors/confluence_history.py new file mode 100644 index 000000000..9e10ffcf1 --- /dev/null +++ b/surfsense_backend/app/connectors/confluence_history.py @@ -0,0 +1,592 @@ +""" +Confluence OAuth Connector. + +Handles OAuth-based authentication and token refresh for Confluence API access. +""" + +import logging +from typing import Any + +import httpx +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.config import config +from app.connectors.confluence_connector import ConfluenceConnector +from app.db import SearchSourceConnector +from app.routes.confluence_add_connector_route import refresh_confluence_token +from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase +from app.utils.oauth_security import TokenEncryption + +logger = logging.getLogger(__name__) + + +class ConfluenceHistoryConnector: + """ + Confluence connector with OAuth support and automatic token refresh. + + This connector uses OAuth 2.0 access tokens to authenticate with the + Confluence API. It automatically refreshes expired tokens when needed. + Also supports legacy API token authentication for backward compatibility. + """ + + def __init__( + self, + session: AsyncSession, + connector_id: int, + credentials: AtlassianAuthCredentialsBase | None = None, + ): + """ + Initialize the ConfluenceHistoryConnector with auto-refresh capability. + + Args: + session: Database session for updating connector + connector_id: Connector ID for direct updates + credentials: Confluence OAuth credentials (optional, will be loaded from DB if not provided) + """ + self._session = session + self._connector_id = connector_id + self._credentials = credentials + self._cloud_id: str | None = None + self._base_url: str | None = None + self._http_client: httpx.AsyncClient | None = None + self._use_oauth = True + self._legacy_email: str | None = None + self._legacy_api_token: str | None = None + self._legacy_confluence_client: ConfluenceConnector | None = None + + async def _get_valid_token(self) -> str: + """ + Get valid Confluence access token, refreshing if needed. + + Returns: + Valid access token + + Raises: + ValueError: If credentials are missing or invalid + Exception: If token refresh fails + """ + # Load credentials from DB if not provided + if self._credentials is None: + result = await self._session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == self._connector_id + ) + ) + connector = result.scalars().first() + + if not connector: + raise ValueError(f"Connector {self._connector_id} not found") + + config_data = connector.config.copy() + + # Check if using OAuth or legacy API token + is_oauth = config_data.get("_token_encrypted", False) or config_data.get( + "access_token" + ) + + if is_oauth: + # OAuth 2.0 authentication + # Decrypt credentials if they are encrypted + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + try: + token_encryption = TokenEncryption(config.SECRET_KEY) + + # Decrypt sensitive fields + if config_data.get("access_token"): + config_data["access_token"] = ( + token_encryption.decrypt_token( + config_data["access_token"] + ) + ) + if config_data.get("refresh_token"): + config_data["refresh_token"] = ( + token_encryption.decrypt_token( + config_data["refresh_token"] + ) + ) + + logger.info( + f"Decrypted Confluence credentials for connector {self._connector_id}" + ) + except Exception as e: + logger.error( + f"Failed to decrypt Confluence credentials for connector {self._connector_id}: {e!s}" + ) + raise ValueError( + f"Failed to decrypt Confluence credentials: {e!s}" + ) from e + + try: + self._credentials = AtlassianAuthCredentialsBase.from_dict( + config_data + ) + # Store cloud_id and base_url for API calls (with backward compatibility for site_url) + self._cloud_id = config_data.get("cloud_id") + self._base_url = config_data.get("base_url") or config_data.get( + "site_url" + ) + self._use_oauth = True + except Exception as e: + raise ValueError( + f"Invalid Confluence OAuth credentials: {e!s}" + ) from e + else: + # Legacy API token authentication + self._legacy_email = config_data.get("CONFLUENCE_EMAIL") + self._legacy_api_token = config_data.get("CONFLUENCE_API_TOKEN") + self._base_url = config_data.get("CONFLUENCE_BASE_URL") + self._use_oauth = False + + if ( + not self._legacy_email + or not self._legacy_api_token + or not self._base_url + ): + raise ValueError( + "Confluence credentials not found in connector config" + ) + + # Check if token is expired and refreshable (only for OAuth) + if ( + self._use_oauth + and self._credentials.is_expired + and self._credentials.is_refreshable + ): + try: + logger.info( + f"Confluence token expired for connector {self._connector_id}, refreshing..." + ) + + # Get connector for refresh + result = await self._session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == self._connector_id + ) + ) + connector = result.scalars().first() + + if not connector: + raise RuntimeError( + f"Connector {self._connector_id} not found; cannot refresh token." + ) + + # Refresh token + connector = await refresh_confluence_token(self._session, connector) + + # Reload credentials after refresh + config_data = connector.config.copy() + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + token_encryption = TokenEncryption(config.SECRET_KEY) + if config_data.get("access_token"): + config_data["access_token"] = token_encryption.decrypt_token( + config_data["access_token"] + ) + if config_data.get("refresh_token"): + config_data["refresh_token"] = token_encryption.decrypt_token( + config_data["refresh_token"] + ) + + self._credentials = AtlassianAuthCredentialsBase.from_dict(config_data) + self._cloud_id = config_data.get("cloud_id") + # Handle backward compatibility: check both base_url and site_url + self._base_url = config_data.get("base_url") or config_data.get( + "site_url" + ) + + # Invalidate cached client so it's recreated with new token + if self._http_client: + await self._http_client.aclose() + self._http_client = None + + logger.info( + f"Successfully refreshed Confluence token for connector {self._connector_id}" + ) + except Exception as e: + logger.error( + f"Failed to refresh Confluence token for connector {self._connector_id}: {e!s}" + ) + raise Exception( + f"Failed to refresh Confluence OAuth credentials: {e!s}" + ) from e + + if self._use_oauth: + return self._credentials.access_token + else: + # For legacy auth, return empty string (not used for token-based auth) + return "" + + async def _get_client(self) -> httpx.AsyncClient: + """ + Get or create HTTP client with valid token. + + Returns: + httpx.AsyncClient instance + """ + if self._http_client is None: + self._http_client = httpx.AsyncClient(timeout=30.0) + return self._http_client + + async def _get_legacy_client(self) -> ConfluenceConnector: + """ + Get or create ConfluenceConnector with legacy credentials. + + Returns: + ConfluenceConnector instance + """ + if self._legacy_confluence_client is None: + self._legacy_confluence_client = ConfluenceConnector( + base_url=self._base_url, + email=self._legacy_email, + api_token=self._legacy_api_token, + ) + return self._legacy_confluence_client + + async def _get_base_url(self) -> str: + """ + Get the base URL for Confluence API calls. + + Returns: + Base URL string + """ + if not self._use_oauth: + # For legacy auth, use the base_url directly + return self._base_url or "" + + if not self._cloud_id: + raise ValueError("Cloud ID not available. Cannot construct API URL.") + + # Use the Atlassian API format: https://api.atlassian.com/ex/confluence/{cloudid} + return f"https://api.atlassian.com/ex/confluence/{self._cloud_id}" + + async def _make_api_request( + self, endpoint: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + """ + Make a request to the Confluence API. + + Args: + endpoint: API endpoint (without base URL) + params: Query parameters for the request (optional) + + Returns: + Response data from the API + + Raises: + ValueError: If credentials have not been set + Exception: If the API request fails + """ + if not self._use_oauth: + # Use legacy ConfluenceConnector for API requests + client = await self._get_legacy_client() + # ConfluenceConnector uses synchronous requests, so we need to handle this differently + # For now, we'll use the legacy client's make_api_request method + # But since it's sync, we'll need to wrap it + import asyncio + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, client.make_api_request, endpoint, params + ) + + # OAuth flow + token = await self._get_valid_token() + base_url = await self._get_base_url() + http_client = await self._get_client() + + url = f"{base_url}/wiki/api/v2/{endpoint}" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + "Accept": "application/json", + } + + try: + response = await http_client.get(url, headers=headers, params=params) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as e: + # Enhanced error logging to see the actual error + error_detail = { + "status_code": e.response.status_code, + "url": str(e.request.url), + "response_text": e.response.text, + "headers": dict(e.response.headers), + } + logger.error(f"Confluence API HTTP error: {error_detail}") + raise Exception( + f"Confluence API request failed (HTTP {e.response.status_code}): {e.response.text}" + ) from e + except httpx.RequestError as e: + logger.error(f"Confluence API request error: {e!s}", exc_info=True) + raise Exception(f"Confluence API request failed: {e!s}") from e + + async def get_all_spaces(self) -> list[dict[str, Any]]: + """ + Fetch all spaces from Confluence. + + Returns: + List of space objects + + Raises: + ValueError: If credentials have not been set + Exception: If the API request fails + """ + params = { + "limit": 100, + } + + all_spaces = [] + cursor = None + + while True: + if cursor: + params["cursor"] = cursor + + result = await self._make_api_request("spaces", params) + + if not isinstance(result, dict) or "results" not in result: + raise Exception("Invalid response from Confluence API") + + spaces = result["results"] + all_spaces.extend(spaces) + + # Check if there are more spaces to fetch + links = result.get("_links", {}) + if "next" not in links: + break + + # Extract cursor from next link if available + next_link = links["next"] + if "cursor=" in next_link: + cursor = next_link.split("cursor=")[1].split("&")[0] + else: + break + + return all_spaces + + async def get_pages_in_space( + self, space_id: str, include_body: bool = True + ) -> list[dict[str, Any]]: + """ + Fetch all pages in a specific space. + + Args: + space_id: The ID of the space to fetch pages from + include_body: Whether to include page body content + + Returns: + List of page objects + + Raises: + ValueError: If credentials have not been set + Exception: If the API request fails + """ + params = { + "limit": 100, + } + + if include_body: + params["body-format"] = "storage" + + all_pages = [] + cursor = None + + while True: + if cursor: + params["cursor"] = cursor + + result = await self._make_api_request(f"spaces/{space_id}/pages", params) + + if not isinstance(result, dict) or "results" not in result: + raise Exception("Invalid response from Confluence API") + + pages = result["results"] + all_pages.extend(pages) + + # Check if there are more pages to fetch + links = result.get("_links", {}) + if "next" not in links: + break + + # Extract cursor from next link if available + next_link = links["next"] + if "cursor=" in next_link: + cursor = next_link.split("cursor=")[1].split("&")[0] + else: + break + + return all_pages + + async def get_page_comments(self, page_id: str) -> list[dict[str, Any]]: + """ + Fetch all comments for a specific page (both footer and inline comments). + + Args: + page_id: The ID of the page to fetch comments from + + Returns: + List of comment objects + + Raises: + ValueError: If credentials have not been set + Exception: If the API request fails + """ + all_comments = [] + + # Get footer comments + footer_comments = await self._get_comments_for_page(page_id, "footer-comments") + all_comments.extend(footer_comments) + + # Get inline comments + inline_comments = await self._get_comments_for_page(page_id, "inline-comments") + all_comments.extend(inline_comments) + + return all_comments + + async def _get_comments_for_page( + self, page_id: str, comment_type: str + ) -> list[dict[str, Any]]: + """ + Helper method to fetch comments of a specific type for a page. + + Args: + page_id: The ID of the page + comment_type: Type of comments ('footer-comments' or 'inline-comments') + + Returns: + List of comment objects + """ + params = { + "limit": 100, + "body-format": "storage", + } + + all_comments = [] + cursor = None + + while True: + if cursor: + params["cursor"] = cursor + + result = await self._make_api_request( + f"pages/{page_id}/{comment_type}", params + ) + + if not isinstance(result, dict) or "results" not in result: + break # No comments or invalid response + + comments = result["results"] + all_comments.extend(comments) + + # Check if there are more comments to fetch + links = result.get("_links", {}) + if "next" not in links: + break + + # Extract cursor from next link if available + next_link = links["next"] + if "cursor=" in next_link: + cursor = next_link.split("cursor=")[1].split("&")[0] + else: + break + + return all_comments + + async def get_pages_by_date_range( + self, + start_date: str, + end_date: str, + space_ids: list[str] | None = None, + include_comments: bool = True, + ) -> tuple[list[dict[str, Any]], str | None]: + """ + Fetch pages within a date range, optionally filtered by spaces. + + Args: + start_date: Start date in YYYY-MM-DD format + end_date: End date in YYYY-MM-DD format (inclusive) + space_ids: Optional list of space IDs to filter pages + include_comments: Whether to include comments for each page + + Returns: + Tuple containing (pages list with comments, error message or None) + """ + try: + if not self._use_oauth: + # Use legacy ConfluenceConnector for API requests + client = await self._get_legacy_client() + # Ensure credentials are loaded + await self._get_valid_token() + # ConfluenceConnector.get_pages_by_date_range is synchronous + import asyncio + + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + None, + client.get_pages_by_date_range, + start_date, + end_date, + space_ids, + include_comments, + ) + + # OAuth flow + all_pages = [] + + if space_ids: + # Fetch pages from specific spaces + for space_id in space_ids: + pages = await self.get_pages_in_space(space_id, include_body=True) + all_pages.extend(pages) + else: + # Fetch all pages (this might be expensive for large instances) + params = { + "limit": 100, + "body-format": "storage", + } + + cursor = None + while True: + if cursor: + params["cursor"] = cursor + + result = await self._make_api_request("pages", params) + if not isinstance(result, dict) or "results" not in result: + break + + pages = result["results"] + all_pages.extend(pages) + + links = result.get("_links", {}) + if "next" not in links: + break + + next_link = links["next"] + if "cursor=" in next_link: + cursor = next_link.split("cursor=")[1].split("&")[0] + else: + break + + return all_pages, None + + except Exception as e: + return [], f"Error fetching pages: {e!s}" + + async def close(self): + """Close the HTTP client connection.""" + if self._http_client: + await self._http_client.aclose() + self._http_client = None + # Legacy client doesn't need explicit closing + self._legacy_confluence_client = None + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() diff --git a/surfsense_backend/app/connectors/jira_connector.py b/surfsense_backend/app/connectors/jira_connector.py index e73198e79..370460e04 100644 --- a/surfsense_backend/app/connectors/jira_connector.py +++ b/surfsense_backend/app/connectors/jira_connector.py @@ -3,6 +3,7 @@ Jira Connector Module A module for retrieving data from Jira. Allows fetching issue lists and their comments, projects and more. +Supports both OAuth 2.0 (preferred) and legacy API token authentication. """ import base64 @@ -18,6 +19,8 @@ class JiraConnector: def __init__( self, base_url: str | None = None, + access_token: str | None = None, + cloud_id: str | None = None, email: str | None = None, api_token: str | None = None, ): @@ -25,18 +28,39 @@ class JiraConnector: Initialize the JiraConnector class. Args: - base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') (optional) - email: Jira account email address (optional) - api_token: Jira API token (optional) + base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') + access_token: OAuth 2.0 access token (preferred method) + cloud_id: Atlassian cloud ID (used with OAuth for API URL construction) + email: Jira account email address (legacy method, used with api_token) + api_token: Jira API token (legacy method, used with email) """ self.base_url = base_url.rstrip("/") if base_url else None + self.access_token = access_token + self.cloud_id = cloud_id self.email = email self.api_token = api_token self.api_version = "3" # Jira Cloud API version + self._use_oauth = access_token is not None + + def set_oauth_credentials( + self, base_url: str, access_token: str, cloud_id: str | None = None + ) -> None: + """ + Set OAuth 2.0 credentials (preferred method). + + Args: + base_url: Jira instance base URL + access_token: OAuth 2.0 access token + cloud_id: Atlassian cloud ID (optional, used for API URL construction) + """ + self.base_url = base_url.rstrip("/") + self.access_token = access_token + self.cloud_id = cloud_id + self._use_oauth = True def set_credentials(self, base_url: str, email: str, api_token: str) -> None: """ - Set the Jira credentials. + Set the Jira credentials (legacy method using API token). Args: base_url: Jira instance base URL @@ -46,50 +70,69 @@ class JiraConnector: self.base_url = base_url.rstrip("/") self.email = email self.api_token = api_token + self._use_oauth = False def set_email(self, email: str) -> None: """ - Set the Jira account email. + Set the Jira account email (legacy method). Args: email: Jira account email address """ self.email = email + self._use_oauth = False def set_api_token(self, api_token: str) -> None: """ - Set the Jira API token. + Set the Jira API token (legacy method). Args: api_token: Jira API token """ self.api_token = api_token + self._use_oauth = False def get_headers(self) -> dict[str, str]: """ - Get headers for Jira API requests using Basic Authentication. + Get headers for Jira API requests. + + Uses OAuth Bearer token if available, otherwise falls back to Basic Auth. Returns: Dictionary of headers Raises: - ValueError: If email, api_token, or base_url have not been set + ValueError: If credentials have not been set """ - if not all([self.base_url, self.email, self.api_token]): - raise ValueError( - "Jira credentials not initialized. Call set_credentials() first." - ) + if self._use_oauth: + # OAuth 2.0 authentication + if not self.base_url or not self.access_token: + raise ValueError( + "Jira OAuth credentials not initialized. Call set_oauth_credentials() first." + ) - # Create Basic Auth header using email:api_token - auth_str = f"{self.email}:{self.api_token}" - auth_bytes = auth_str.encode("utf-8") - auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii") + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}", + "Accept": "application/json", + } + else: + # Legacy Basic Auth + if not all([self.base_url, self.email, self.api_token]): + raise ValueError( + "Jira credentials not initialized. Call set_credentials() first." + ) - return { - "Content-Type": "application/json", - "Authorization": auth_header, - "Accept": "application/json", - } + # Create Basic Auth header using email:api_token + auth_str = f"{self.email}:{self.api_token}" + auth_bytes = auth_str.encode("utf-8") + auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii") + + return { + "Content-Type": "application/json", + "Authorization": auth_header, + "Accept": "application/json", + } def make_api_request( self, @@ -104,22 +147,26 @@ class JiraConnector: Args: endpoint: API endpoint (without base URL) params: Query parameters for the request (optional) + method: HTTP method (GET or POST) + json_payload: JSON payload for POST requests (optional) Returns: Response data from the API Raises: - ValueError: If email, api_token, or base_url have not been set + ValueError: If credentials have not been set Exception: If the API request fails """ - if not all([self.base_url, self.email, self.api_token]): - raise ValueError( - "Jira credentials not initialized. Call set_credentials() first." - ) - - url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}" headers = self.get_headers() + # Construct API URL based on authentication method + if self._use_oauth and self.cloud_id: + # Use Atlassian API gateway with cloud_id for OAuth + url = f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/{self.api_version}/{endpoint}" + else: + # Use direct base URL (works for both OAuth and legacy) + url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}" + if method.upper() == "POST": response = requests.post( url, headers=headers, json=json_payload, timeout=500 @@ -234,16 +281,23 @@ class JiraConnector: try: # Build JQL query for date range # Query issues that were either created OR updated within the date range - date_filter = ( - f"(createdDate >= '{start_date}' AND createdDate <= '{end_date}')" - ) - # TODO : This JQL needs some improvement to work as expected + # Use end_date + 1 day with < operator to include the full end date + from datetime import datetime, timedelta - jql = f"{date_filter}" + # Parse end_date and add 1 day for inclusive end date + end_date_obj = datetime.strptime(end_date, "%Y-%m-%d") + end_date_next = (end_date_obj + timedelta(days=1)).strftime("%Y-%m-%d") + + # Check both created and updated dates to catch all relevant issues + # Use 'created' and 'updated' (standard JQL field names) + date_filter = ( + f"(created >= '{start_date}' AND created < '{end_date_next}') " + f"OR (updated >= '{start_date}' AND updated < '{end_date_next}')" + ) + + jql = f"{date_filter} ORDER BY created DESC" if project_key: - jql = ( - f'project = "{project_key}" AND {date_filter} ORDER BY created DESC' - ) + jql = f'project = "{project_key}" AND ({date_filter}) ORDER BY created DESC' # Define fields to retrieve fields = [ diff --git a/surfsense_backend/app/connectors/jira_history.py b/surfsense_backend/app/connectors/jira_history.py new file mode 100644 index 000000000..6e04ec2a4 --- /dev/null +++ b/surfsense_backend/app/connectors/jira_history.py @@ -0,0 +1,331 @@ +""" +Jira OAuth Connector. + +Handles OAuth-based authentication and token refresh for Jira API access. +Supports both OAuth 2.0 (preferred) and legacy API token authentication. +""" + +import logging +from typing import Any + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.config import config +from app.connectors.jira_connector import JiraConnector +from app.db import SearchSourceConnector +from app.routes.jira_add_connector_route import refresh_jira_token +from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase +from app.utils.oauth_security import TokenEncryption + +logger = logging.getLogger(__name__) + + +class JiraHistoryConnector: + """ + Jira connector with OAuth support and automatic token refresh. + + This connector uses OAuth 2.0 access tokens to authenticate with the + Jira API. It automatically refreshes expired tokens when needed. + Also supports legacy API token authentication for backward compatibility. + """ + + def __init__( + self, + session: AsyncSession, + connector_id: int, + credentials: AtlassianAuthCredentialsBase | None = None, + ): + """ + Initialize the JiraHistoryConnector with auto-refresh capability. + + Args: + session: Database session for updating connector + connector_id: Connector ID for direct updates + credentials: Jira OAuth credentials (optional, will be loaded from DB if not provided) + """ + self._session = session + self._connector_id = connector_id + self._credentials = credentials + self._cloud_id: str | None = None + self._base_url: str | None = None + self._jira_client: JiraConnector | None = None + self._use_oauth = True + self._legacy_email: str | None = None + self._legacy_api_token: str | None = None + + async def _get_valid_token(self) -> str: + """ + Get valid Jira access token, refreshing if needed. + + Returns: + Valid access token + + Raises: + ValueError: If credentials are missing or invalid + Exception: If token refresh fails + """ + # Load credentials from DB if not provided + if self._credentials is None: + result = await self._session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == self._connector_id + ) + ) + connector = result.scalars().first() + + if not connector: + raise ValueError(f"Connector {self._connector_id} not found") + + config_data = connector.config.copy() + + # Check if using OAuth or legacy API token + is_oauth = config_data.get("_token_encrypted", False) or config_data.get( + "access_token" + ) + + if is_oauth: + # OAuth 2.0 authentication + if not config.SECRET_KEY: + raise ValueError( + "SECRET_KEY not configured but tokens are marked as encrypted" + ) + + try: + token_encryption = TokenEncryption(config.SECRET_KEY) + + # Decrypt access_token + if config_data.get("access_token"): + config_data["access_token"] = token_encryption.decrypt_token( + config_data["access_token"] + ) + logger.info( + f"Decrypted Jira access token for connector {self._connector_id}" + ) + + # Decrypt refresh_token if present + if config_data.get("refresh_token"): + config_data["refresh_token"] = token_encryption.decrypt_token( + config_data["refresh_token"] + ) + logger.info( + f"Decrypted Jira refresh token for connector {self._connector_id}" + ) + except Exception as e: + logger.error( + f"Failed to decrypt Jira credentials for connector {self._connector_id}: {e!s}" + ) + raise ValueError( + f"Failed to decrypt Jira credentials: {e!s}" + ) from e + + try: + self._credentials = AtlassianAuthCredentialsBase.from_dict( + config_data + ) + self._cloud_id = config_data.get("cloud_id") + self._base_url = config_data.get("base_url") + self._use_oauth = True + except Exception as e: + raise ValueError(f"Invalid Jira OAuth credentials: {e!s}") from e + else: + # Legacy API token authentication + self._legacy_email = config_data.get("JIRA_EMAIL") + self._legacy_api_token = config_data.get("JIRA_API_TOKEN") + self._base_url = config_data.get("JIRA_BASE_URL") + self._use_oauth = False + + if ( + not self._legacy_email + or not self._legacy_api_token + or not self._base_url + ): + raise ValueError("Jira credentials not found in connector config") + + # Check if token is expired and refreshable (only for OAuth) + if ( + self._use_oauth + and self._credentials.is_expired + and self._credentials.is_refreshable + ): + try: + logger.info( + f"Jira token expired for connector {self._connector_id}, refreshing..." + ) + + # Get connector for refresh + result = await self._session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == self._connector_id + ) + ) + connector = result.scalars().first() + + if not connector: + raise RuntimeError( + f"Connector {self._connector_id} not found; cannot refresh token." + ) + + # Refresh token + connector = await refresh_jira_token(self._session, connector) + + # Reload credentials after refresh + config_data = connector.config.copy() + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + token_encryption = TokenEncryption(config.SECRET_KEY) + if config_data.get("access_token"): + config_data["access_token"] = token_encryption.decrypt_token( + config_data["access_token"] + ) + if config_data.get("refresh_token"): + config_data["refresh_token"] = token_encryption.decrypt_token( + config_data["refresh_token"] + ) + + self._credentials = AtlassianAuthCredentialsBase.from_dict(config_data) + self._cloud_id = config_data.get("cloud_id") + self._base_url = config_data.get("base_url") + + # Invalidate cached client so it's recreated with new token + self._jira_client = None + + logger.info( + f"Successfully refreshed Jira token for connector {self._connector_id}" + ) + except Exception as e: + logger.error( + f"Failed to refresh Jira token for connector {self._connector_id}: {e!s}" + ) + raise Exception( + f"Failed to refresh Jira OAuth credentials: {e!s}" + ) from e + + if self._use_oauth: + return self._credentials.access_token + else: + # For legacy auth, return empty string (not used for token-based auth) + return "" + + async def _get_jira_client(self) -> JiraConnector: + """ + Get or create JiraConnector with valid credentials. + + Returns: + JiraConnector instance + """ + if self._jira_client is None: + if self._use_oauth: + # Ensure we have valid token (will refresh if needed) + await self._get_valid_token() + + self._jira_client = JiraConnector( + base_url=self._base_url, + access_token=self._credentials.access_token, + cloud_id=self._cloud_id, + ) + else: + # Legacy API token authentication + self._jira_client = JiraConnector( + base_url=self._base_url, + email=self._legacy_email, + api_token=self._legacy_api_token, + ) + else: + # If OAuth, refresh token if expired before returning client + if self._use_oauth: + await self._get_valid_token() + # Update client with new token if it was refreshed + if self._credentials: + self._jira_client.set_oauth_credentials( + base_url=self._base_url or "", + access_token=self._credentials.access_token, + cloud_id=self._cloud_id, + ) + + return self._jira_client + + async def get_issues_by_date_range( + self, + start_date: str, + end_date: str, + include_comments: bool = True, + project_key: str | None = None, + ) -> tuple[list[dict[str, Any]], str | None]: + """ + Fetch issues within a date range. + This method wraps JiraConnector.get_issues_by_date_range() with automatic token refresh. + + Args: + start_date: Start date in YYYY-MM-DD format + end_date: End date in YYYY-MM-DD format (inclusive) + include_comments: Whether to include comments in the response + project_key: Optional project key to filter issues + + Returns: + Tuple containing (issues list, error message or None) + """ + # Ensure token is valid (will refresh if needed) + if self._use_oauth: + await self._get_valid_token() + + # Get client with valid credentials + client = await self._get_jira_client() + + # JiraConnector methods are synchronous, so we call them directly + # Token refresh has already been handled above + return client.get_issues_by_date_range( + start_date=start_date, + end_date=end_date, + include_comments=include_comments, + project_key=project_key, + ) + + def format_issue(self, issue: dict[str, Any]) -> dict[str, Any]: + """ + Format an issue for easier consumption. + Wraps JiraConnector.format_issue(). + + Args: + issue: The issue object from Jira API + + Returns: + Formatted issue dictionary + """ + # This is a synchronous method that doesn't need token refresh + # since it just formats data that's already been fetched + if self._jira_client is None: + # Create a minimal client just for formatting (doesn't need credentials) + self._jira_client = JiraConnector() + return self._jira_client.format_issue(issue) + + def format_issue_to_markdown(self, issue: dict[str, Any]) -> str: + """ + Convert an issue to markdown format. + Wraps JiraConnector.format_issue_to_markdown(). + + Args: + issue: The issue object (either raw or formatted) + + Returns: + Markdown string representation of the issue + """ + # This is a synchronous method that doesn't need token refresh + # since it just formats data that's already been fetched + if self._jira_client is None: + # Create a minimal client just for formatting (doesn't need credentials) + self._jira_client = JiraConnector() + return self._jira_client.format_issue_to_markdown(issue) + + async def close(self): + """Close any resources (currently no-op for JiraConnector).""" + # JiraConnector doesn't maintain persistent connections, so nothing to close + self._jira_client = None + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index b35d743e0..5015b80c2 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -4,6 +4,8 @@ from .airtable_add_connector_route import ( router as airtable_add_connector_router, ) from .circleback_webhook_route import router as circleback_webhook_router +from .confluence_add_connector_route import router as confluence_add_connector_router +from .discord_add_connector_route import router as discord_add_connector_router from .documents_routes import router as documents_router from .editor_routes import router as editor_router from .google_calendar_add_connector_route import ( @@ -15,6 +17,7 @@ from .google_drive_add_connector_route import ( from .google_gmail_add_connector_route import ( router as google_gmail_add_connector_router, ) +from .jira_add_connector_route import router as jira_add_connector_router from .linear_add_connector_route import router as linear_add_connector_router from .logs_routes import router as logs_router from .luma_add_connector_route import router as luma_add_connector_router @@ -27,7 +30,6 @@ from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router from .slack_add_connector_route import router as slack_add_connector_router -from .discord_add_connector_route import router as discord_add_connector_router router = APIRouter() @@ -48,6 +50,8 @@ router.include_router(luma_add_connector_router) router.include_router(notion_add_connector_router) router.include_router(slack_add_connector_router) router.include_router(discord_add_connector_router) +router.include_router(jira_add_connector_router) +router.include_router(confluence_add_connector_router) router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks diff --git a/surfsense_backend/app/routes/confluence_add_connector_route.py b/surfsense_backend/app/routes/confluence_add_connector_route.py new file mode 100644 index 000000000..e86d411b6 --- /dev/null +++ b/surfsense_backend/app/routes/confluence_add_connector_route.py @@ -0,0 +1,485 @@ +""" +Confluence Connector OAuth Routes. + +Handles OAuth 2.0 authentication flow for Confluence connector. +""" + +import logging +from datetime import UTC, datetime, timedelta +from uuid import UUID + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request +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 ( + SearchSourceConnector, + SearchSourceConnectorType, + User, + get_async_session, +) +from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase +from app.users import current_active_user +from app.utils.oauth_security import OAuthStateManager, TokenEncryption + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Atlassian OAuth endpoints +AUTHORIZATION_URL = "https://auth.atlassian.com/authorize" +TOKEN_URL = "https://auth.atlassian.com/oauth/token" +RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources" + +# OAuth scopes for Confluence +SCOPES = [ + "read:confluence-user", + "read:space:confluence", + "read:page:confluence", + "read:comment:confluence", + "offline_access", # Required for refresh tokens +] + +# Initialize security utilities +_state_manager = None +_token_encryption = None + + +def get_state_manager() -> OAuthStateManager: + """Get or create OAuth state manager instance.""" + global _state_manager + if _state_manager is None: + if not config.SECRET_KEY: + raise ValueError("SECRET_KEY must be set for OAuth security") + _state_manager = OAuthStateManager(config.SECRET_KEY) + return _state_manager + + +def get_token_encryption() -> TokenEncryption: + """Get or create token encryption instance.""" + global _token_encryption + if _token_encryption is None: + if not config.SECRET_KEY: + raise ValueError("SECRET_KEY must be set for token encryption") + _token_encryption = TokenEncryption(config.SECRET_KEY) + return _token_encryption + + +@router.get("/auth/confluence/connector/add") +async def connect_confluence(space_id: int, user: User = Depends(current_active_user)): + """ + Initiate Confluence OAuth flow. + + Args: + space_id: The search space ID + user: Current authenticated user + + Returns: + Authorization URL for redirect + """ + try: + if not space_id: + raise HTTPException(status_code=400, detail="space_id is required") + + if not config.ATLASSIAN_CLIENT_ID: + raise HTTPException( + status_code=500, detail="Atlassian OAuth not configured." + ) + + if not config.SECRET_KEY: + raise HTTPException( + status_code=500, detail="SECRET_KEY not configured for OAuth security." + ) + + # Generate secure state parameter with HMAC signature + state_manager = get_state_manager() + state_encoded = state_manager.generate_secure_state(space_id, user.id) + + # Build authorization URL + from urllib.parse import urlencode + + auth_params = { + "audience": "api.atlassian.com", + "client_id": config.ATLASSIAN_CLIENT_ID, + "scope": " ".join(SCOPES), + "redirect_uri": config.CONFLUENCE_REDIRECT_URI, + "state": state_encoded, + "response_type": "code", + "prompt": "consent", + } + + auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}" + + logger.info( + f"Generated Confluence OAuth URL for user {user.id}, space {space_id}" + ) + return {"auth_url": auth_url} + + except Exception as e: + logger.error(f"Failed to initiate Confluence OAuth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to initiate Confluence OAuth: {e!s}" + ) from e + + +@router.get("/auth/confluence/connector/callback") +async def confluence_callback( + request: Request, + code: str | None = None, + error: str | None = None, + state: str | None = None, + session: AsyncSession = Depends(get_async_session), +): + """ + Handle Confluence OAuth callback. + + Args: + request: FastAPI request object + code: Authorization code from Atlassian (if user granted access) + error: Error code from Atlassian (if user denied access or error occurred) + state: State parameter containing user/space info + session: Database session + + Returns: + Redirect response to frontend + """ + try: + # Handle OAuth errors (e.g., user denied access) + if error: + logger.warning(f"Confluence OAuth error: {error}") + # Try to decode state to get space_id for redirect, but don't fail if it's invalid + space_id = None + if state: + try: + state_manager = get_state_manager() + data = state_manager.validate_state(state) + space_id = data.get("space_id") + except Exception: + # If state is invalid, we'll redirect without space_id + logger.warning("Failed to validate state in error handler") + + # Redirect to frontend with error parameter + if space_id: + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=confluence_oauth_denied" + ) + else: + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=confluence_oauth_denied" + ) + + # Validate required parameters for successful flow + if not code: + raise HTTPException(status_code=400, detail="Missing authorization code") + if not state: + raise HTTPException(status_code=400, detail="Missing state parameter") + + # Validate and decode state with signature verification + state_manager = get_state_manager() + try: + data = state_manager.validate_state(state) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Invalid state parameter: {e!s}" + ) from e + + user_id = UUID(data["user_id"]) + space_id = data["space_id"] + + # Validate redirect URI (security: ensure it matches configured value) + if not config.CONFLUENCE_REDIRECT_URI: + raise HTTPException( + status_code=500, detail="CONFLUENCE_REDIRECT_URI not configured" + ) + + # Exchange authorization code for access token + token_data = { + "grant_type": "authorization_code", + "client_id": config.ATLASSIAN_CLIENT_ID, + "client_secret": config.ATLASSIAN_CLIENT_SECRET, + "code": code, + "redirect_uri": config.CONFLUENCE_REDIRECT_URI, + } + + async with httpx.AsyncClient() as client: + token_response = await client.post( + TOKEN_URL, + json=token_data, + headers={"Content-Type": "application/json"}, + timeout=30.0, + ) + + if token_response.status_code != 200: + error_detail = token_response.text + try: + error_json = token_response.json() + error_detail = error_json.get( + "error_description", error_json.get("error", error_detail) + ) + except Exception: + pass + raise HTTPException( + status_code=400, detail=f"Token exchange failed: {error_detail}" + ) + + token_json = token_response.json() + + access_token = token_json.get("access_token") + refresh_token = token_json.get("refresh_token") + if not access_token: + raise HTTPException( + status_code=400, detail="No access token received from Atlassian" + ) + + # Get accessible resources to find Confluence cloud ID and site URL + async with httpx.AsyncClient() as client: + resources_response = await client.get( + RESOURCES_URL, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30.0, + ) + + cloud_id = None + site_url = None + if resources_response.status_code == 200: + resources = resources_response.json() + # Find Confluence resource + for resource in resources: + if resource.get("id") and resource.get("name"): + cloud_id = resource.get("id") + site_url = resource.get("url") + break + + if not cloud_id: + logger.warning( + "Could not determine Confluence cloud ID from accessible resources" + ) + + # Calculate expiration time (UTC, tz-aware) + expires_at = None + expires_in = token_json.get("expires_in") + if expires_in: + now_utc = datetime.now(UTC) + expires_at = now_utc + timedelta(seconds=int(expires_in)) + + # Encrypt sensitive tokens before storing + token_encryption = get_token_encryption() + + # Store the encrypted tokens and metadata in connector config + connector_config = { + "access_token": token_encryption.encrypt_token(access_token), + "refresh_token": token_encryption.encrypt_token(refresh_token) + if refresh_token + else None, + "token_type": token_json.get("token_type", "Bearer"), + "expires_in": expires_in, + "expires_at": expires_at.isoformat() if expires_at else None, + "scope": token_json.get("scope"), + "cloud_id": cloud_id, + "base_url": site_url, # Store as base_url to match shared schema + # Mark that tokens are encrypted for backward compatibility + "_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, + ) + ) + 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() + logger.info(f"Successfully saved Confluence connector for user {user_id}") + + # 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" + ) + + except ValidationError as e: + await session.rollback() + raise HTTPException( + status_code=422, detail=f"Validation error: {e!s}" + ) from e + except IntegrityError as e: + await session.rollback() + raise HTTPException( + status_code=409, + detail=f"Integrity error: A connector with this type already exists. {e!s}", + ) from e + except Exception as e: + logger.error(f"Failed to create search source connector: {e!s}") + await session.rollback() + raise HTTPException( + status_code=500, + detail=f"Failed to create search source connector: {e!s}", + ) from e + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to complete Confluence OAuth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to complete Confluence OAuth: {e!s}" + ) from e + + +async def refresh_confluence_token( + session: AsyncSession, connector: SearchSourceConnector +) -> SearchSourceConnector: + """ + Refresh the Confluence access token for a connector. + + Args: + session: Database session + connector: Confluence connector to refresh + + Returns: + Updated connector object + """ + try: + logger.info(f"Refreshing Confluence token for connector {connector.id}") + + credentials = AtlassianAuthCredentialsBase.from_dict(connector.config) + + # Decrypt tokens if they are encrypted + token_encryption = get_token_encryption() + is_encrypted = connector.config.get("_token_encrypted", False) + + refresh_token = credentials.refresh_token + if is_encrypted and refresh_token: + try: + refresh_token = token_encryption.decrypt_token(refresh_token) + except Exception as e: + logger.error(f"Failed to decrypt refresh token: {e!s}") + raise HTTPException( + status_code=500, detail="Failed to decrypt stored refresh token" + ) from e + + if not refresh_token: + raise HTTPException( + status_code=400, + detail="No refresh token available. Please re-authenticate.", + ) + + # Prepare token refresh data + refresh_data = { + "grant_type": "refresh_token", + "client_id": config.ATLASSIAN_CLIENT_ID, + "client_secret": config.ATLASSIAN_CLIENT_SECRET, + "refresh_token": refresh_token, + } + + async with httpx.AsyncClient() as client: + token_response = await client.post( + TOKEN_URL, + json=refresh_data, + headers={"Content-Type": "application/json"}, + timeout=30.0, + ) + + if token_response.status_code != 200: + error_detail = token_response.text + try: + error_json = token_response.json() + error_detail = error_json.get( + "error_description", error_json.get("error", error_detail) + ) + except Exception: + pass + raise HTTPException( + status_code=400, detail=f"Token refresh failed: {error_detail}" + ) + + token_json = token_response.json() + + # Calculate expiration time (UTC, tz-aware) + expires_at = None + expires_in = token_json.get("expires_in") + if expires_in: + now_utc = datetime.now(UTC) + expires_at = now_utc + timedelta(seconds=int(expires_in)) + + # Encrypt new tokens before storing + access_token = token_json.get("access_token") + new_refresh_token = token_json.get("refresh_token") + + if not access_token: + raise HTTPException( + status_code=400, + detail="No access token received from Confluence refresh", + ) + + # Update credentials object with encrypted tokens + credentials.access_token = token_encryption.encrypt_token(access_token) + if new_refresh_token: + credentials.refresh_token = token_encryption.encrypt_token( + new_refresh_token + ) + credentials.expires_in = expires_in + credentials.expires_at = expires_at + credentials.scope = token_json.get("scope") + + # Preserve cloud_id and base_url (with backward compatibility for site_url) + if not credentials.cloud_id: + credentials.cloud_id = connector.config.get("cloud_id") + if not credentials.base_url: + # Check both base_url and site_url for backward compatibility + credentials.base_url = connector.config.get( + "base_url" + ) or connector.config.get("site_url") + + # Update connector config with encrypted tokens + credentials_dict = credentials.to_dict() + credentials_dict["_token_encrypted"] = True + connector.config = credentials_dict + await session.commit() + await session.refresh(connector) + + logger.info( + f"Successfully refreshed Confluence token for connector {connector.id}" + ) + + return connector + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to refresh Confluence token: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to refresh Confluence token: {e!s}" + ) from e diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py index 70a0046a3..6bebac718 100644 --- a/surfsense_backend/app/routes/discord_add_connector_route.py +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -217,7 +217,9 @@ async def discord_callback( error_detail = token_response.text try: error_json = token_response.json() - error_detail = error_json.get("error_description", error_json.get("error", error_detail)) + error_detail = error_json.get( + "error_description", error_json.get("error", error_detail) + ) except Exception: pass raise HTTPException( @@ -263,7 +265,9 @@ async def discord_callback( # Store the bot token from config and OAuth metadata connector_config = { - "bot_token": token_encryption.encrypt_token(bot_token), # Use bot token from config + "bot_token": token_encryption.encrypt_token( + bot_token + ), # Use bot token from config "oauth_access_token": token_encryption.encrypt_token(oauth_access_token) if oauth_access_token else None, # Store OAuth token for reference @@ -356,7 +360,7 @@ async def refresh_discord_token( ) -> SearchSourceConnector: """ Refresh the Discord OAuth tokens for a connector. - + Note: Bot tokens from config don't expire, but OAuth access tokens might. This function refreshes OAuth tokens if needed, but always uses bot token from config. @@ -400,7 +404,9 @@ async def refresh_discord_token( f"No refresh token available for connector {connector.id}. Using bot token from config." ) # Update bot token from config (in case it was changed) - credentials.bot_token = token_encryption.encrypt_token(config.DISCORD_BOT_TOKEN) + credentials.bot_token = token_encryption.encrypt_token( + config.DISCORD_BOT_TOKEN + ) credentials_dict = credentials.to_dict() credentials_dict["_token_encrypted"] = True connector.config = credentials_dict @@ -428,7 +434,9 @@ async def refresh_discord_token( error_detail = token_response.text try: error_json = token_response.json() - error_detail = error_json.get("error_description", error_json.get("error", error_detail)) + error_detail = error_json.get( + "error_description", error_json.get("error", error_detail) + ) except Exception: pass # If refresh fails, bot token from config is still valid @@ -437,7 +445,9 @@ async def refresh_discord_token( "Using bot token from config." ) # Update bot token from config - credentials.bot_token = token_encryption.encrypt_token(config.DISCORD_BOT_TOKEN) + credentials.bot_token = token_encryption.encrypt_token( + config.DISCORD_BOT_TOKEN + ) credentials.refresh_token = None # Clear invalid refresh token credentials_dict = credentials.to_dict() credentials_dict["_token_encrypted"] = True @@ -463,7 +473,7 @@ async def refresh_discord_token( # Always use bot token from config (bot tokens don't expire) credentials.bot_token = token_encryption.encrypt_token(config.DISCORD_BOT_TOKEN) - + # Update OAuth tokens if available if oauth_access_token: # Store OAuth access token for reference @@ -493,7 +503,9 @@ async def refresh_discord_token( await session.commit() await session.refresh(connector) - logger.info(f"Successfully refreshed Discord OAuth tokens for connector {connector.id}") + logger.info( + f"Successfully refreshed Discord OAuth tokens for connector {connector.id}" + ) return connector except HTTPException: @@ -506,4 +518,3 @@ async def refresh_discord_token( raise HTTPException( status_code=500, detail=f"Failed to refresh Discord tokens: {e!s}" ) from e - diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py new file mode 100644 index 000000000..740c30300 --- /dev/null +++ b/surfsense_backend/app/routes/jira_add_connector_route.py @@ -0,0 +1,497 @@ +""" +Jira Connector OAuth Routes. + +Handles OAuth 2.0 authentication flow for Jira connector. +Uses Atlassian OAuth 2.0 (3LO) with accessible-resources API to discover Jira instances. +""" + +import logging +from datetime import UTC, datetime, timedelta +from uuid import UUID + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request +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 ( + SearchSourceConnector, + SearchSourceConnectorType, + User, + get_async_session, +) +from app.schemas.atlassian_auth_credentials import AtlassianAuthCredentialsBase +from app.users import current_active_user +from app.utils.oauth_security import OAuthStateManager, TokenEncryption + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Atlassian OAuth endpoints +AUTHORIZATION_URL = "https://auth.atlassian.com/authorize" +TOKEN_URL = "https://auth.atlassian.com/oauth/token" +ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources" + +# OAuth scopes for Jira +SCOPES = [ + "read:jira-work", + "read:jira-user", + "offline_access", # Required for refresh tokens +] + +# Initialize security utilities +_state_manager = None +_token_encryption = None + + +def get_state_manager() -> OAuthStateManager: + """Get or create OAuth state manager instance.""" + global _state_manager + if _state_manager is None: + if not config.SECRET_KEY: + raise ValueError("SECRET_KEY must be set for OAuth security") + _state_manager = OAuthStateManager(config.SECRET_KEY) + return _state_manager + + +def get_token_encryption() -> TokenEncryption: + """Get or create token encryption instance.""" + global _token_encryption + if _token_encryption is None: + if not config.SECRET_KEY: + raise ValueError("SECRET_KEY must be set for token encryption") + _token_encryption = TokenEncryption(config.SECRET_KEY) + return _token_encryption + + +@router.get("/auth/jira/connector/add") +async def connect_jira(space_id: int, user: User = Depends(current_active_user)): + """ + Initiate Jira OAuth flow. + + Args: + space_id: The search space ID + user: Current authenticated user + + Returns: + Authorization URL for redirect + """ + try: + if not space_id: + raise HTTPException(status_code=400, detail="space_id is required") + + if not config.ATLASSIAN_CLIENT_ID: + raise HTTPException( + status_code=500, detail="Atlassian OAuth not configured." + ) + + if not config.SECRET_KEY: + raise HTTPException( + status_code=500, detail="SECRET_KEY not configured for OAuth security." + ) + + # Generate secure state parameter with HMAC signature + state_manager = get_state_manager() + state_encoded = state_manager.generate_secure_state(space_id, user.id) + + # Build authorization URL + from urllib.parse import urlencode + + auth_params = { + "audience": "api.atlassian.com", + "client_id": config.ATLASSIAN_CLIENT_ID, + "scope": " ".join(SCOPES), + "redirect_uri": config.JIRA_REDIRECT_URI, + "state": state_encoded, + "response_type": "code", + "prompt": "consent", # Force consent screen to get refresh token + } + + auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}" + + logger.info(f"Generated Jira OAuth URL for user {user.id}, space {space_id}") + return {"auth_url": auth_url} + + except Exception as e: + logger.error(f"Failed to initiate Jira OAuth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to initiate Jira OAuth: {e!s}" + ) from e + + +@router.get("/auth/jira/connector/callback") +async def jira_callback( + request: Request, + code: str | None = None, + error: str | None = None, + state: str | None = None, + session: AsyncSession = Depends(get_async_session), +): + """ + Handle Jira OAuth callback. + + Args: + request: FastAPI request object + code: Authorization code from Atlassian (if user granted access) + error: Error code from Atlassian (if user denied access or error occurred) + state: State parameter containing user/space info + session: Database session + + Returns: + Redirect response to frontend + """ + try: + # Handle OAuth errors (e.g., user denied access) + if error: + logger.warning(f"Jira OAuth error: {error}") + # Try to decode state to get space_id for redirect, but don't fail if it's invalid + space_id = None + if state: + try: + state_manager = get_state_manager() + data = state_manager.validate_state(state) + space_id = data.get("space_id") + except Exception: + # If state is invalid, we'll redirect without space_id + logger.warning("Failed to validate state in error handler") + + # Redirect to frontend with error parameter + if space_id: + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=jira_oauth_denied" + ) + else: + return RedirectResponse( + url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=jira_oauth_denied" + ) + + # Validate required parameters for successful flow + if not code: + raise HTTPException(status_code=400, detail="Missing authorization code") + if not state: + raise HTTPException(status_code=400, detail="Missing state parameter") + + # Validate and decode state with signature verification + state_manager = get_state_manager() + try: + data = state_manager.validate_state(state) + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Invalid state parameter: {e!s}" + ) from e + + user_id = UUID(data["user_id"]) + space_id = data["space_id"] + + # Validate redirect URI (security: ensure it matches configured value) + if not config.JIRA_REDIRECT_URI: + raise HTTPException( + status_code=500, detail="JIRA_REDIRECT_URI not configured" + ) + + # Exchange authorization code for access token + token_data = { + "grant_type": "authorization_code", + "client_id": config.ATLASSIAN_CLIENT_ID, + "client_secret": config.ATLASSIAN_CLIENT_SECRET, + "code": code, + "redirect_uri": config.JIRA_REDIRECT_URI, + } + + async with httpx.AsyncClient() as client: + token_response = await client.post( + TOKEN_URL, + data=token_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30.0, + ) + + if token_response.status_code != 200: + error_detail = token_response.text + try: + error_json = token_response.json() + error_detail = error_json.get( + "error_description", error_json.get("error", error_detail) + ) + except Exception: + pass + raise HTTPException( + status_code=400, detail=f"Token exchange failed: {error_detail}" + ) + + token_json = token_response.json() + + # Encrypt sensitive tokens before storing + token_encryption = get_token_encryption() + access_token = token_json.get("access_token") + refresh_token = token_json.get("refresh_token") + + if not access_token: + raise HTTPException( + status_code=400, detail="No access token received from Atlassian" + ) + + # Fetch accessible resources to get Jira instance information + async with httpx.AsyncClient() as client: + resources_response = await client.get( + ACCESSIBLE_RESOURCES_URL, + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30.0, + ) + + if resources_response.status_code != 200: + error_detail = resources_response.text + logger.error(f"Failed to fetch accessible resources: {error_detail}") + raise HTTPException( + status_code=400, + detail=f"Failed to fetch Jira instances: {error_detail}", + ) + + resources = resources_response.json() + + # Filter for Jira instances (resources with type "jira" or id field) + jira_instances = [ + r for r in resources if r.get("id") and (r.get("name") or r.get("url")) + ] + + if not jira_instances: + raise HTTPException( + status_code=400, + detail="No accessible Jira instances found. Please ensure you have access to at least one Jira instance.", + ) + + # For now, use the first Jira instance + # TODO: Support multiple instances by letting user choose during OAuth + jira_instance = jira_instances[0] + cloud_id = jira_instance["id"] + base_url = jira_instance.get("url") + + # If URL is not provided, construct it from cloud_id + if not base_url: + # Try to extract from name or construct default format + instance_name = jira_instance.get("name", "").lower().replace(" ", "") + if instance_name: + base_url = f"https://{instance_name}.atlassian.net" + else: + # Fallback: use cloud_id directly (though this may not work) + base_url = f"https://{cloud_id}.atlassian.net" + + # Calculate expiration time (UTC, tz-aware) + expires_at = None + expires_in = token_json.get("expires_in") + if expires_in: + now_utc = datetime.now(UTC) + expires_at = now_utc + timedelta(seconds=int(expires_in)) + + # Store the encrypted access token and refresh token in connector config + connector_config = { + "access_token": token_encryption.encrypt_token(access_token), + "refresh_token": token_encryption.encrypt_token(refresh_token) + if refresh_token + else None, + "token_type": token_json.get("token_type", "Bearer"), + "expires_in": expires_in, + "expires_at": expires_at.isoformat() if expires_at else None, + "scope": token_json.get("scope"), + "cloud_id": cloud_id, + "base_url": base_url.rstrip("/") if base_url else None, + # Mark that tokens are encrypted for backward compatibility + "_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, + ) + ) + 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() + logger.info(f"Successfully saved Jira connector for user {user_id}") + + # 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" + ) + + except ValidationError as e: + await session.rollback() + raise HTTPException( + status_code=422, detail=f"Validation error: {e!s}" + ) from e + except IntegrityError as e: + await session.rollback() + raise HTTPException( + status_code=409, + detail=f"Integrity error: A connector with this type already exists. {e!s}", + ) from e + except Exception as e: + logger.error(f"Failed to create search source connector: {e!s}") + await session.rollback() + raise HTTPException( + status_code=500, + detail=f"Failed to create search source connector: {e!s}", + ) from e + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to complete Jira OAuth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to complete Jira OAuth: {e!s}" + ) from e + + +async def refresh_jira_token( + session: AsyncSession, connector: SearchSourceConnector +) -> SearchSourceConnector: + """ + Refresh the Jira access token for a connector. + + Args: + session: Database session + connector: Jira connector to refresh + + Returns: + Updated connector object + """ + try: + logger.info(f"Refreshing Jira token for connector {connector.id}") + + credentials = AtlassianAuthCredentialsBase.from_dict(connector.config) + + # Decrypt tokens if they are encrypted + token_encryption = get_token_encryption() + is_encrypted = connector.config.get("_token_encrypted", False) + + refresh_token = credentials.refresh_token + if is_encrypted and refresh_token: + try: + refresh_token = token_encryption.decrypt_token(refresh_token) + except Exception as e: + logger.error(f"Failed to decrypt refresh token: {e!s}") + raise HTTPException( + status_code=500, detail="Failed to decrypt stored refresh token" + ) from e + + if not refresh_token: + raise HTTPException( + status_code=400, + detail="No refresh token available. Please re-authenticate.", + ) + + # Prepare token refresh data + refresh_data = { + "grant_type": "refresh_token", + "client_id": config.ATLASSIAN_CLIENT_ID, + "client_secret": config.ATLASSIAN_CLIENT_SECRET, + "refresh_token": refresh_token, + } + + async with httpx.AsyncClient() as client: + token_response = await client.post( + TOKEN_URL, + data=refresh_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30.0, + ) + + if token_response.status_code != 200: + error_detail = token_response.text + try: + error_json = token_response.json() + error_detail = error_json.get( + "error_description", error_json.get("error", error_detail) + ) + except Exception: + pass + raise HTTPException( + status_code=400, detail=f"Token refresh failed: {error_detail}" + ) + + token_json = token_response.json() + + # Calculate expiration time (UTC, tz-aware) + expires_at = None + expires_in = token_json.get("expires_in") + if expires_in: + now_utc = datetime.now(UTC) + expires_at = now_utc + timedelta(seconds=int(expires_in)) + + # Encrypt new tokens before storing + access_token = token_json.get("access_token") + new_refresh_token = token_json.get("refresh_token") + + if not access_token: + raise HTTPException( + status_code=400, detail="No access token received from Jira refresh" + ) + + # Update credentials object with encrypted tokens + credentials.access_token = token_encryption.encrypt_token(access_token) + if new_refresh_token: + credentials.refresh_token = token_encryption.encrypt_token( + new_refresh_token + ) + credentials.expires_in = expires_in + credentials.expires_at = expires_at + credentials.scope = token_json.get("scope") + + # Preserve cloud_id and base_url + if not credentials.cloud_id: + credentials.cloud_id = connector.config.get("cloud_id") + if not credentials.base_url: + credentials.base_url = connector.config.get("base_url") + + # Update connector config with encrypted tokens + credentials_dict = credentials.to_dict() + credentials_dict["_token_encrypted"] = True + connector.config = credentials_dict + await session.commit() + await session.refresh(connector) + + logger.info(f"Successfully refreshed Jira token for connector {connector.id}") + + return connector + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to refresh Jira token: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to refresh Jira token: {e!s}" + ) from e diff --git a/surfsense_backend/app/schemas/atlassian_auth_credentials.py b/surfsense_backend/app/schemas/atlassian_auth_credentials.py new file mode 100644 index 000000000..cbb4772e6 --- /dev/null +++ b/surfsense_backend/app/schemas/atlassian_auth_credentials.py @@ -0,0 +1,86 @@ +""" +Atlassian OAuth 2.0 Authentication Credentials Schema. + +Shared schema for both Jira and Confluence OAuth credentials. +Both products use the same Atlassian OAuth 2.0 (3LO) flow and token structure. +""" + +from datetime import UTC, datetime + +from pydantic import BaseModel, field_validator + + +class AtlassianAuthCredentialsBase(BaseModel): + """ + Base model for Atlassian OAuth 2.0 credentials. + + Used for both Jira and Confluence connectors since they share + the same Atlassian OAuth infrastructure and token structure. + """ + + access_token: str + refresh_token: str | None = None + token_type: str = "Bearer" + expires_in: int | None = None + expires_at: datetime | None = None + scope: str | None = None + cloud_id: str | None = None + base_url: str | None = None + + @property + def is_expired(self) -> bool: + """Check if the credentials have expired.""" + if self.expires_at is None: + return False + return self.expires_at <= datetime.now(UTC) + + @property + def is_refreshable(self) -> bool: + """Check if the credentials can be refreshed.""" + return self.refresh_token is not None + + def to_dict(self) -> dict: + """Convert credentials to dictionary for storage.""" + return { + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "token_type": self.token_type, + "expires_in": self.expires_in, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "scope": self.scope, + "cloud_id": self.cloud_id, + "base_url": self.base_url, + } + + @classmethod + def from_dict(cls, data: dict) -> "AtlassianAuthCredentialsBase": + """Create credentials from dictionary.""" + expires_at = None + if data.get("expires_at"): + expires_at = datetime.fromisoformat(data["expires_at"]) + + return cls( + access_token=data["access_token"], + refresh_token=data.get("refresh_token"), + token_type=data.get("token_type", "Bearer"), + expires_in=data.get("expires_in"), + expires_at=expires_at, + scope=data.get("scope"), + cloud_id=data.get("cloud_id"), + base_url=data.get("base_url"), + ) + + @field_validator("expires_at", mode="before") + @classmethod + def ensure_aware_utc(cls, v): + # Strings like "2025-08-26T14:46:57.367184" + if isinstance(v, str): + # add +00:00 if missing tz info + if v.endswith("Z"): + return datetime.fromisoformat(v.replace("Z", "+00:00")) + dt = datetime.fromisoformat(v) + return dt if dt.tzinfo else dt.replace(tzinfo=UTC) + # datetime objects + if isinstance(v, datetime): + return v if v.tzinfo else v.replace(tzinfo=UTC) + return v diff --git a/surfsense_backend/app/schemas/discord_auth_credentials.py b/surfsense_backend/app/schemas/discord_auth_credentials.py index 0c18a7554..7ea4ee55c 100644 --- a/surfsense_backend/app/schemas/discord_auth_credentials.py +++ b/surfsense_backend/app/schemas/discord_auth_credentials.py @@ -73,4 +73,3 @@ class DiscordAuthCredentialsBase(BaseModel): if isinstance(v, datetime): return v if v.tzinfo else v.replace(tzinfo=UTC) return v - diff --git a/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py b/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py index d5e68fb8f..09022a30b 100644 --- a/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/confluence_indexer.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.config import config -from app.connectors.confluence_connector import ConfluenceConnector +from app.connectors.confluence_history import ConfluenceHistoryConnector from app.db import Document, DocumentType, SearchSourceConnectorType from app.services.llm_service import get_user_long_context_llm from app.services.task_logging_service import TaskLoggingService @@ -83,31 +83,18 @@ async def index_confluence_pages( ) return 0, f"Connector with ID {connector_id} not found" - # Get the Confluence credentials from the connector config - confluence_email = connector.config.get("CONFLUENCE_EMAIL") - confluence_api_token = connector.config.get("CONFLUENCE_API_TOKEN") - confluence_base_url = connector.config.get("CONFLUENCE_BASE_URL") - - if not confluence_email or not confluence_api_token or not confluence_base_url: - await task_logger.log_task_failure( - log_entry, - f"Confluence credentials not found in connector config for connector {connector_id}", - "Missing Confluence credentials", - {"error_type": "MissingCredentials"}, - ) - return 0, "Confluence credentials not found in connector config" - - # Initialize Confluence client + # Initialize Confluence OAuth client await task_logger.log_task_progress( log_entry, - f"Initializing Confluence client for connector {connector_id}", + f"Initializing Confluence OAuth client for connector {connector_id}", {"stage": "client_initialization"}, ) - confluence_client = ConfluenceConnector( - base_url=confluence_base_url, - email=confluence_email, - api_token=confluence_api_token, + confluence_client: ConfluenceHistoryConnector | None = ( + ConfluenceHistoryConnector( + session=session, + connector_id=connector_id, + ) ) # Calculate date range @@ -127,7 +114,7 @@ async def index_confluence_pages( # Get pages within date range try: - pages, error = confluence_client.get_pages_by_date_range( + pages, error = await confluence_client.get_pages_by_date_range( start_date=start_date_str, end_date=end_date_str, include_comments=True ) @@ -153,6 +140,12 @@ async def index_confluence_pages( f"No Confluence pages found in date range {start_date_str} to {end_date_str}", {"pages_found": 0}, ) + # Close client before returning + if confluence_client: + try: + await confluence_client.close() + except Exception: + pass return 0, None else: await task_logger.log_task_failure( @@ -161,12 +154,24 @@ async def index_confluence_pages( "API Error", {"error_type": "APIError"}, ) + # Close client on error + if confluence_client: + try: + await confluence_client.close() + except Exception: + pass return 0, f"Failed to get Confluence pages: {error}" logger.info(f"Retrieved {len(pages)} pages from Confluence API") except Exception as e: logger.error(f"Error fetching Confluence pages: {e!s}", exc_info=True) + # Close client on error + if confluence_client: + try: + await confluence_client.close() + except Exception: + pass return 0, f"Error fetching Confluence pages: {e!s}" # Process and index each page @@ -418,6 +423,11 @@ async def index_confluence_pages( logger.info( f"Confluence indexing completed: {documents_indexed} new pages, {documents_skipped} skipped" ) + + # Close the client connection + if confluence_client: + await confluence_client.close() + return ( total_processed, None, @@ -425,6 +435,12 @@ async def index_confluence_pages( except SQLAlchemyError as db_error: await session.rollback() + # Close client if it exists + if confluence_client: + try: + await confluence_client.close() + except Exception: + pass await task_logger.log_task_failure( log_entry, f"Database error during Confluence indexing for connector {connector_id}", @@ -435,6 +451,12 @@ async def index_confluence_pages( return 0, f"Database error: {db_error!s}" except Exception as e: await session.rollback() + # Close client if it exists + if confluence_client: + try: + await confluence_client.close() + except Exception: + pass await task_logger.log_task_failure( log_entry, f"Failed to index Confluence pages for connector {connector_id}", diff --git a/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py b/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py index 5c92d2601..110732831 100644 --- a/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py @@ -69,7 +69,9 @@ async def index_discord_messages( try: # Normalize date parameters - handle 'undefined' strings from frontend - if start_date and (start_date.lower() == "undefined" or start_date.strip() == ""): + if start_date and ( + start_date.lower() == "undefined" or start_date.strip() == "" + ): start_date = None if end_date and (end_date.lower() == "undefined" or end_date.strip() == ""): end_date = None @@ -118,12 +120,13 @@ async def index_discord_messages( elif has_legacy: # Backward compatibility: use legacy token format discord_token = connector.config.get("DISCORD_BOT_TOKEN") - + # Decrypt token if it's encrypted (legacy tokens might be encrypted) token_encrypted = connector.config.get("_token_encrypted", False) if token_encrypted and config.SECRET_KEY and discord_token: try: from app.utils.oauth_security import TokenEncryption + token_encryption = TokenEncryption(config.SECRET_KEY) discord_token = token_encryption.decrypt_token(discord_token) logger.info( @@ -135,7 +138,7 @@ async def index_discord_messages( "Trying to use token as-is (might be unencrypted)." ) # Continue with token as-is - might be unencrypted legacy token - + discord_client = DiscordConnector(token=discord_token) else: await task_logger.log_task_failure( @@ -210,11 +213,16 @@ async def index_discord_messages( f"Date parsing error: {e!s}", {"error_type": "InvalidDateFormat", "start_date": start_date}, ) - return 0, f"Invalid start_date format: {start_date}. Expected YYYY-MM-DD format." + return ( + 0, + f"Invalid start_date format: {start_date}. Expected YYYY-MM-DD format.", + ) try: end_date_iso = ( - datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC).isoformat() + datetime.strptime(end_date, "%Y-%m-%d") + .replace(tzinfo=UTC) + .isoformat() ) except ValueError as e: await task_logger.log_task_failure( @@ -223,7 +231,10 @@ async def index_discord_messages( f"Date parsing error: {e!s}", {"error_type": "InvalidDateFormat", "end_date": end_date}, ) - return 0, f"Invalid end_date format: {end_date}. Expected YYYY-MM-DD format." + return ( + 0, + f"Invalid end_date format: {end_date}. Expected YYYY-MM-DD format.", + ) logger.info( f"Indexing Discord messages from {start_date_iso} to {end_date_iso}" @@ -384,8 +395,10 @@ async def index_discord_messages( ) # Check if document with this unique identifier already exists - existing_document = await check_document_by_unique_identifier( - session, unique_identifier_hash + existing_document = ( + await check_document_by_unique_identifier( + session, unique_identifier_hash + ) ) if existing_document: @@ -406,8 +419,10 @@ async def index_discord_messages( chunks = await create_document_chunks( combined_document_string ) - doc_embedding = config.embedding_model_instance.embed( - combined_document_string + doc_embedding = ( + config.embedding_model_instance.embed( + combined_document_string + ) ) # Update existing document @@ -429,7 +444,9 @@ async def index_discord_messages( # Delete old chunks and add new ones existing_document.chunks = chunks - existing_document.updated_at = get_current_timestamp() + existing_document.updated_at = ( + get_current_timestamp() + ) documents_indexed += 1 logger.info( @@ -439,7 +456,9 @@ async def index_discord_messages( # Document doesn't exist - create new one # Process chunks - chunks = await create_document_chunks(combined_document_string) + chunks = await create_document_chunks( + combined_document_string + ) doc_embedding = config.embedding_model_instance.embed( combined_document_string ) diff --git a/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py b/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py index 8c56b10ab..7209deb49 100644 --- a/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py @@ -8,7 +8,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from app.config import config -from app.connectors.jira_connector import JiraConnector +from app.connectors.jira_history import JiraHistoryConnector from app.db import Document, DocumentType, SearchSourceConnectorType from app.services.llm_service import get_user_long_context_llm from app.services.task_logging_service import TaskLoggingService @@ -83,32 +83,27 @@ async def index_jira_issues( ) return 0, f"Connector with ID {connector_id} not found" - # Get the Jira credentials from the connector config - jira_email = connector.config.get("JIRA_EMAIL") - jira_api_token = connector.config.get("JIRA_API_TOKEN") - jira_base_url = connector.config.get("JIRA_BASE_URL") - - if not jira_email or not jira_api_token or not jira_base_url: - await task_logger.log_task_failure( - log_entry, - f"Jira credentials not found in connector config for connector {connector_id}", - "Missing Jira credentials", - {"error_type": "MissingCredentials"}, - ) - return 0, "Jira credentials not found in connector config" - - # Initialize Jira client + # Initialize Jira client with internal refresh capability + # Token refresh will happen automatically when needed await task_logger.log_task_progress( log_entry, f"Initializing Jira client for connector {connector_id}", {"stage": "client_initialization"}, ) - jira_client = JiraConnector( - base_url=jira_base_url, email=jira_email, api_token=jira_api_token - ) + logger.info(f"Initializing Jira client for connector {connector_id}") + + # Create connector with session and connector_id for internal refresh + # Token refresh will happen automatically when needed + jira_client = JiraHistoryConnector(session=session, connector_id=connector_id) # Calculate date range + # Handle "undefined" strings from frontend + if start_date == "undefined" or start_date == "": + start_date = None + if end_date == "undefined" or end_date == "": + end_date = None + start_date_str, end_date_str = calculate_date_range( connector, start_date, end_date, default_days_back=365 ) @@ -125,7 +120,7 @@ async def index_jira_issues( # Get issues within date range try: - issues, error = jira_client.get_issues_by_date_range( + issues, error = await jira_client.get_issues_by_date_range( start_date=start_date_str, end_date=end_date_str, include_comments=True ) @@ -398,6 +393,10 @@ async def index_jira_issues( logger.info( f"JIRA indexing completed: {documents_indexed} new issues, {documents_skipped} skipped" ) + + # Clean up the connector + await jira_client.close() + return ( total_processed, None, @@ -412,6 +411,12 @@ async def index_jira_issues( {"error_type": "SQLAlchemyError"}, ) logger.error(f"Database error: {db_error!s}", exc_info=True) + # Clean up the connector in case of error + if "jira_client" in locals(): + try: + await jira_client.close() + except Exception: + pass return 0, f"Database error: {db_error!s}" except Exception as e: await session.rollback() @@ -422,4 +427,10 @@ async def index_jira_issues( {"error_type": type(e).__name__}, ) logger.error(f"Failed to index JIRA issues: {e!s}", exc_info=True) + # Clean up the connector in case of error + if "jira_client" in locals(): + try: + await jira_client.close() + except Exception: + pass return 0, f"Failed to index JIRA issues: {e!s}" diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index f1620c0e5..adc8f9ee7 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -538,28 +538,19 @@ def validate_connector_config( }, }, # "DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}}, - "JIRA_CONNECTOR": { - "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], - "validators": { - "JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"), - "JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"), - }, - }, - "CONFLUENCE_CONNECTOR": { - "required": [ - "CONFLUENCE_BASE_URL", - "CONFLUENCE_EMAIL", - "CONFLUENCE_API_TOKEN", - ], - "validators": { - "CONFLUENCE_EMAIL": lambda: validate_email_field( - "CONFLUENCE_EMAIL", "Confluence" - ), - "CONFLUENCE_BASE_URL": lambda: validate_url_field( - "CONFLUENCE_BASE_URL", "Confluence" - ), - }, - }, + # "JIRA_CONNECTOR": { + # "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], + # "validators": { + # "JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"), + # "JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"), + # }, + # }, + # "CONFLUENCE_CONNECTOR": { + # "required": [ + # "access_token", + # ], + # "validators": {}, + # }, "CLICKUP_CONNECTOR": {"required": ["CLICKUP_API_TOKEN"], "validators": {}}, # "GOOGLE_CALENDAR_CONNECTOR": { # "required": ["token", "refresh_token", "token_uri", "client_id", "expiry", "scopes", "client_secret"], diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/confluence-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/confluence-connect-form.tsx deleted file mode 100644 index 83f6c6ec7..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/confluence-connect-form.tsx +++ /dev/null @@ -1,451 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Info } from "lucide-react"; -import type { FC } from "react"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { EnumConnectorName } from "@/contracts/enums/connector"; -import { DateRangeSelector } from "../../components/date-range-selector"; -import { getConnectorBenefits } from "../connector-benefits"; -import type { ConnectFormProps } from "../index"; - -const confluenceConnectorFormSchema = z.object({ - name: z.string().min(3, { - message: "Connector name must be at least 3 characters.", - }), - base_url: z.string().url({ message: "Please enter a valid Confluence base URL." }), - email: z.string().email({ message: "Please enter a valid email address." }), - api_token: z.string().min(10, { - message: "Confluence API Token is required and must be valid.", - }), -}); - -type ConfluenceConnectorFormValues = z.infer; - -export const ConfluenceConnectForm: FC = ({ onSubmit, isSubmitting }) => { - const isSubmittingRef = useRef(false); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); - const form = useForm({ - resolver: zodResolver(confluenceConnectorFormSchema), - defaultValues: { - name: "Confluence Connector", - base_url: "", - email: "", - api_token: "", - }, - }); - - const handleSubmit = async (values: ConfluenceConnectorFormValues) => { - // Prevent multiple submissions - if (isSubmittingRef.current || isSubmitting) { - return; - } - - isSubmittingRef.current = true; - try { - await onSubmit({ - name: values.name, - connector_type: EnumConnectorName.CONFLUENCE_CONNECTOR, - config: { - CONFLUENCE_BASE_URL: values.base_url, - CONFLUENCE_EMAIL: values.email, - CONFLUENCE_API_TOKEN: values.api_token, - }, - is_indexable: true, - last_indexed_at: null, - periodic_indexing_enabled: periodicEnabled, - indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, - next_scheduled_at: null, - startDate, - endDate, - periodicEnabled, - frequencyMinutes, - }); - } finally { - isSubmittingRef.current = false; - } - }; - - return ( -
- - -
- API Token Required - - You'll need a Confluence API Token to use this connector. You can create one from{" "} - - Atlassian Account Settings - - -
-
- -
-
- - ( - - Connector Name - - - - - A friendly name to identify this connector. - - - - )} - /> - - ( - - Confluence Base URL - - - - - The base URL of your Confluence instance (e.g., - https://your-domain.atlassian.net). - - - - )} - /> - - ( - - Email Address - - - - - The email address associated with your Atlassian account. - - - - )} - /> - - ( - - API Token - - - - - Your Confluence API Token will be encrypted and stored securely. - - - - )} - /> - - {/* Indexing Configuration */} -
-

Indexing Configuration

- - {/* Date Range Selector */} - - - {/* Periodic Sync Config */} -
-
-
-

Enable Periodic Sync

-

- Automatically re-index at regular intervals -

-
- -
- - {periodicEnabled && ( -
-
- - -
-
- )} -
-
- - -
- - {/* What you get section */} - {getConnectorBenefits(EnumConnectorName.CONFLUENCE_CONNECTOR) && ( -
-

- What you get with Confluence integration: -

-
    - {getConnectorBenefits(EnumConnectorName.CONFLUENCE_CONNECTOR)?.map((benefit) => ( -
  • {benefit}
  • - ))} -
-
- )} - - {/* Documentation Section */} - - - - Documentation - - -
-

How it works

-

- The Confluence connector uses the Confluence REST API to fetch all pages and - comments that your account has access to within your Confluence instance. -

-
    -
  • - For follow up indexing runs, the connector retrieves pages and comments that have - been updated since the last indexing attempt. -
  • -
  • - Indexing is configured to run periodically, so updates should appear in your - search results within minutes. -
  • -
-
- -
-
-

Authorization

- - - - Read-Only Access is Sufficient - - - You only need read access for this connector to work. The API Token will only be - used to read your Confluence data. - - - -
-
-

- Step 1: Create an API Token -

-
    -
  1. Log in to your Atlassian account
  2. -
  3. - Navigate to{" "} - - https://id.atlassian.com/manage-profile/security/api-tokens - {" "} - in your browser. -
  4. -
  5. - Click Create API token -
  6. -
  7. Enter a label for your token (like "SurfSense Connector")
  8. -
  9. - Click Create -
  10. -
  11. Copy the generated token as it will only be shown once
  12. -
-
- -
-

- Step 2: Grant necessary access -

-

- The API Token will have access to all spaces and pages that your user account - can see. Make sure your account has appropriate permissions for the spaces you - want to index. -

- - - Data Privacy - - Only pages, comments, and basic metadata will be indexed. Confluence - attachments and linked files are not indexed by this connector. - - -
-
-
-
- -
-
-

Indexing

-
    -
  1. - Navigate to the Connector Dashboard and select the Confluence{" "} - Connector. -
  2. -
  3. - Enter your Confluence Instance URL (e.g., - https://yourcompany.atlassian.net) -
  4. -
  5. - Enter your Email Address associated with your Atlassian account -
  6. -
  7. - Place your API Token in the form field. -
  8. -
  9. - Click Connect to establish the connection. -
  10. -
  11. Once connected, your Confluence pages will be indexed automatically.
  12. -
- - - - What Gets Indexed - -

The Confluence connector indexes the following data:

-
    -
  • All pages from accessible spaces
  • -
  • Page content and metadata
  • -
  • Comments on pages (both footer and inline comments)
  • -
  • Page titles and descriptions
  • -
-
-
-
-
-
-
-
-
- ); -}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx deleted file mode 100644 index 0499554b4..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx +++ /dev/null @@ -1,450 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Info } from "lucide-react"; -import type { FC } from "react"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { EnumConnectorName } from "@/contracts/enums/connector"; -import { DateRangeSelector } from "../../components/date-range-selector"; -import { getConnectorBenefits } from "../connector-benefits"; -import type { ConnectFormProps } from "../index"; - -const jiraConnectorFormSchema = z.object({ - name: z.string().min(3, { - message: "Connector name must be at least 3 characters.", - }), - base_url: z.string().url({ message: "Please enter a valid Jira base URL." }), - email: z.string().email({ message: "Please enter a valid email address." }), - api_token: z.string().min(10, { - message: "Jira API Token is required and must be valid.", - }), -}); - -type JiraConnectorFormValues = z.infer; - -export const JiraConnectForm: FC = ({ onSubmit, isSubmitting }) => { - const isSubmittingRef = useRef(false); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); - const form = useForm({ - resolver: zodResolver(jiraConnectorFormSchema), - defaultValues: { - name: "Jira Connector", - base_url: "", - email: "", - api_token: "", - }, - }); - - const handleSubmit = async (values: JiraConnectorFormValues) => { - // Prevent multiple submissions - if (isSubmittingRef.current || isSubmitting) { - return; - } - - isSubmittingRef.current = true; - try { - await onSubmit({ - name: values.name, - connector_type: EnumConnectorName.JIRA_CONNECTOR, - config: { - JIRA_BASE_URL: values.base_url, - JIRA_EMAIL: values.email, - JIRA_API_TOKEN: values.api_token, - }, - is_indexable: true, - last_indexed_at: null, - periodic_indexing_enabled: periodicEnabled, - indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, - next_scheduled_at: null, - startDate, - endDate, - periodicEnabled, - frequencyMinutes, - }); - } finally { - isSubmittingRef.current = false; - } - }; - - return ( -
- - -
- API Token Required - - You'll need a Jira API Token to use this connector. You can create one from{" "} - - Atlassian Account Settings - - -
-
- -
-
- - ( - - Connector Name - - - - - A friendly name to identify this connector. - - - - )} - /> - - ( - - Jira Base URL - - - - - The base URL of your Jira instance (e.g., https://your-domain.atlassian.net). - - - - )} - /> - - ( - - Email Address - - - - - The email address associated with your Atlassian account. - - - - )} - /> - - ( - - API Token - - - - - Your Jira API Token will be encrypted and stored securely. - - - - )} - /> - - {/* Indexing Configuration */} -
-

Indexing Configuration

- - {/* Date Range Selector */} - - - {/* Periodic Sync Config */} -
-
-
-

Enable Periodic Sync

-

- Automatically re-index at regular intervals -

-
- -
- - {periodicEnabled && ( -
-
- - -
-
- )} -
-
- - -
- - {/* What you get section */} - {getConnectorBenefits(EnumConnectorName.JIRA_CONNECTOR) && ( -
-

What you get with Jira integration:

-
    - {getConnectorBenefits(EnumConnectorName.JIRA_CONNECTOR)?.map((benefit) => ( -
  • {benefit}
  • - ))} -
-
- )} - - {/* Documentation Section */} - - - - Documentation - - -
-

How it works

-

- The Jira connector uses the Jira REST API with Basic Authentication to fetch all - issues and comments that your account has access to within your Jira instance. -

-
    -
  • - For follow up indexing runs, the connector retrieves issues and comments that have - been updated since the last indexing attempt. -
  • -
  • - Indexing is configured to run periodically, so updates should appear in your - search results within minutes. -
  • -
-
- -
-
-

Authorization

- - - - Read-Only Access is Sufficient - - - You only need read access for this connector to work. The API Token will only be - used to read your Jira data. - - - -
-
-

- Step 1: Create an API Token -

-
    -
  1. Log in to your Atlassian account
  2. -
  3. - Navigate to{" "} - - https://id.atlassian.com/manage-profile/security/api-tokens - {" "} - in your browser. -
  4. -
  5. - Click Create API token -
  6. -
  7. Enter a label for your token (like "SurfSense Connector")
  8. -
  9. - Click Create -
  10. -
  11. Copy the generated token as it will only be shown once
  12. -
-
- -
-

- Step 2: Grant necessary access -

-

- The API Token will have access to all projects and issues that your user - account can see. Make sure your account has appropriate permissions for the - projects you want to index. -

- - - Data Privacy - - Only issues, comments, and basic metadata will be indexed. Jira attachments - and linked files are not indexed by this connector. - - -
-
-
-
- -
-
-

Indexing

-
    -
  1. - Navigate to the Connector Dashboard and select the Jira{" "} - Connector. -
  2. -
  3. - Enter your Jira Instance URL (e.g., - https://yourcompany.atlassian.net) -
  4. -
  5. - Enter your Email Address associated with your Atlassian account -
  6. -
  7. - Place your API Token in the form field. -
  8. -
  9. - Click Connect to establish the connection. -
  10. -
  11. Once connected, your Jira issues will be indexed automatically.
  12. -
- - - - What Gets Indexed - -

The Jira connector indexes the following data:

-
    -
  • Issue keys and summaries (e.g., PROJ-123)
  • -
  • Issue descriptions
  • -
  • Issue comments and discussion threads
  • -
  • Issue status, priority, and type information
  • -
  • Assignee and reporter information
  • -
  • Project information
  • -
-
-
-
-
-
-
-
-
- ); -}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx index 81e5ee03f..86a70b5bf 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx @@ -3,10 +3,8 @@ import { BaiduSearchApiConnectForm } from "./components/baidu-search-api-connect import { BookStackConnectForm } from "./components/bookstack-connect-form"; import { CirclebackConnectForm } from "./components/circleback-connect-form"; import { ClickUpConnectForm } from "./components/clickup-connect-form"; -import { ConfluenceConnectForm } from "./components/confluence-connect-form"; import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form"; import { GithubConnectForm } from "./components/github-connect-form"; -import { JiraConnectForm } from "./components/jira-connect-form"; import { LinkupApiConnectForm } from "./components/linkup-api-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form"; import { SearxngConnectForm } from "./components/searxng-connect-form"; @@ -49,14 +47,10 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo return BaiduSearchApiConnectForm; case "ELASTICSEARCH_CONNECTOR": return ElasticsearchConnectForm; - case "CONFLUENCE_CONNECTOR": - return ConfluenceConnectForm; case "BOOKSTACK_CONNECTOR": return BookStackConnectForm; case "GITHUB_CONNECTOR": return GithubConnectForm; - case "JIRA_CONNECTOR": - return JiraConnectForm; case "CLICKUP_CONNECTOR": return ClickUpConnectForm; case "LUMA_CONNECTOR": diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx index c3a233406..59fa89554 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/confluence-config.tsx @@ -1,6 +1,6 @@ "use client"; -import { KeyRound } from "lucide-react"; +import { Info, KeyRound } from "lucide-react"; import type { FC } from "react"; import { useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; @@ -16,6 +16,9 @@ export const ConfluenceConfig: FC = ({ onConfigChange, onNameChange, }) => { + // Check if this is an OAuth connector (has access_token or _token_encrypted flag) + const isOAuth = !!(connector.config?.access_token || connector.config?._token_encrypted); + const [baseUrl, setBaseUrl] = useState( (connector.config?.CONFLUENCE_BASE_URL as string) || "" ); @@ -25,16 +28,18 @@ export const ConfluenceConfig: FC = ({ ); const [name, setName] = useState(connector.name || ""); - // Update values when connector changes + // Update values when connector changes (only for legacy connectors) useEffect(() => { - const url = (connector.config?.CONFLUENCE_BASE_URL as string) || ""; - const emailVal = (connector.config?.CONFLUENCE_EMAIL as string) || ""; - const token = (connector.config?.CONFLUENCE_API_TOKEN as string) || ""; - setBaseUrl(url); - setEmail(emailVal); - setApiToken(token); + if (!isOAuth) { + const url = (connector.config?.CONFLUENCE_BASE_URL as string) || ""; + const emailVal = (connector.config?.CONFLUENCE_EMAIL as string) || ""; + const token = (connector.config?.CONFLUENCE_API_TOKEN as string) || ""; + setBaseUrl(url); + setEmail(emailVal); + setApiToken(token); + } setName(connector.name || ""); - }, [connector.config, connector.name]); + }, [connector.config, connector.name, isOAuth]); const handleBaseUrlChange = (value: string) => { setBaseUrl(value); @@ -73,6 +78,35 @@ export const ConfluenceConfig: FC = ({ } }; + // For OAuth connectors, show simple info message + if (isOAuth) { + const siteUrl = + (connector.config?.base_url as string) || (connector.config?.site_url as string) || "Unknown"; + return ( +
+ {/* OAuth Info */} +
+
+ +
+
+

Connected via OAuth

+

+ This connector is authenticated using OAuth 2.0. Your Confluence instance is: +

+

+ {siteUrl} +

+

+ To update your connection, reconnect this connector. +

+
+
+
+ ); + } + + // For legacy API token connectors, show the form return (
{/* Connector Name */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx index 464bc438f..dd4c89c8e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx @@ -18,9 +18,9 @@ export const DiscordConfig: FC = () => {

Add Bot to Servers

- Before indexing, make sure the Discord bot has been added to the servers (guilds) you want to - index. The bot can only access messages from servers it's been added to. Use the OAuth - authorization flow to add the bot to your servers. + Before indexing, make sure the Discord bot has been added to the servers (guilds) you + want to index. The bot can only access messages from servers it's been added to. Use the + OAuth authorization flow to add the bot to your servers.

diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx index 3ef16bdb4..dcc83c2d6 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx @@ -1,6 +1,6 @@ "use client"; -import { KeyRound } from "lucide-react"; +import { Info, KeyRound } from "lucide-react"; import type { FC } from "react"; import { useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; @@ -12,6 +12,9 @@ export interface JiraConfigProps extends ConnectorConfigProps { } export const JiraConfig: FC = ({ connector, onConfigChange, onNameChange }) => { + // Check if this is an OAuth connector (has access_token or _token_encrypted flag) + const isOAuth = !!(connector.config?.access_token || connector.config?._token_encrypted); + const [baseUrl, setBaseUrl] = useState((connector.config?.JIRA_BASE_URL as string) || ""); const [email, setEmail] = useState((connector.config?.JIRA_EMAIL as string) || ""); const [apiToken, setApiToken] = useState( @@ -19,16 +22,18 @@ export const JiraConfig: FC = ({ connector, onConfigChange, onN ); const [name, setName] = useState(connector.name || ""); - // Update values when connector changes + // Update values when connector changes (only for legacy connectors) useEffect(() => { - const url = (connector.config?.JIRA_BASE_URL as string) || ""; - const emailVal = (connector.config?.JIRA_EMAIL as string) || ""; - const token = (connector.config?.JIRA_API_TOKEN as string) || ""; - setBaseUrl(url); - setEmail(emailVal); - setApiToken(token); + if (!isOAuth) { + const url = (connector.config?.JIRA_BASE_URL as string) || ""; + const emailVal = (connector.config?.JIRA_EMAIL as string) || ""; + const token = (connector.config?.JIRA_API_TOKEN as string) || ""; + setBaseUrl(url); + setEmail(emailVal); + setApiToken(token); + } setName(connector.name || ""); - }, [connector.config, connector.name]); + }, [connector.config, connector.name, isOAuth]); const handleBaseUrlChange = (value: string) => { setBaseUrl(value); @@ -67,6 +72,34 @@ export const JiraConfig: FC = ({ connector, onConfigChange, onN } }; + // For OAuth connectors, show simple info message + if (isOAuth) { + const baseUrl = (connector.config?.base_url as string) || "Unknown"; + return ( +
+ {/* OAuth Info */} +
+
+ +
+
+

Connected via OAuth

+

+ This connector is authenticated using OAuth 2.0. Your Jira instance is: +

+

+ {baseUrl} +

+

+ To update your connection, reconnect this connector. +

+
+
+
+ ); + } + + // For legacy API token connectors, show the form return (
{/* Connector Name */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx index 3ba03f956..22dff4322 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -52,10 +52,8 @@ export const ConnectorConnectView: FC = ({ LINKUP_API: "linkup-api-connect-form", BAIDU_SEARCH_API: "baidu-search-api-connect-form", ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form", - CONFLUENCE_CONNECTOR: "confluence-connect-form", BOOKSTACK_CONNECTOR: "bookstack-connect-form", GITHUB_CONNECTOR: "github-connect-form", - JIRA_CONNECTOR: "jira-connect-form", CLICKUP_CONNECTOR: "clickup-connect-form", LUMA_CONNECTOR: "luma-connect-form", CIRCLEBACK_CONNECTOR: "circleback-connect-form", diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 9822ff6e6..4d15d0989 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -58,6 +58,20 @@ export const OAUTH_CONNECTORS = [ connectorType: EnumConnectorName.DISCORD_CONNECTOR, authEndpoint: "/api/v1/auth/discord/connector/add/", }, + { + id: "jira-connector", + title: "Jira", + description: "Search Jira issues", + connectorType: EnumConnectorName.JIRA_CONNECTOR, + authEndpoint: "/api/v1/auth/jira/connector/add/", + }, + { + id: "confluence-connector", + title: "Confluence", + description: "Search documentation", + connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR, + authEndpoint: "/api/v1/auth/confluence/connector/add/", + }, ] as const; // Content Sources (tools that extract and import content from external sources) @@ -78,12 +92,6 @@ export const CRAWLERS = [ // Non-OAuth Connectors (redirect to old connector config pages) export const OTHER_CONNECTORS = [ - { - id: "confluence-connector", - title: "Confluence", - description: "Search documentation", - connectorType: EnumConnectorName.CONFLUENCE_CONNECTOR, - }, { id: "bookstack-connector", title: "BookStack", @@ -96,12 +104,6 @@ export const OTHER_CONNECTORS = [ description: "Search repositories", connectorType: EnumConnectorName.GITHUB_CONNECTOR, }, - { - id: "jira-connector", - title: "Jira", - description: "Search Jira issues", - connectorType: EnumConnectorName.JIRA_CONNECTOR, - }, { id: "clickup-connector", title: "ClickUp", diff --git a/surfsense_web/hooks/use-connector-edit-page.ts b/surfsense_web/hooks/use-connector-edit-page.ts index 3beb80247..a1a3c88f4 100644 --- a/surfsense_web/hooks/use-connector-edit-page.ts +++ b/surfsense_web/hooks/use-connector-edit-page.ts @@ -446,7 +446,17 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) }; } break; - case "JIRA_CONNECTOR": + case "JIRA_CONNECTOR": { + // Check if this is an OAuth connector (has access_token or _token_encrypted flag) + const isJiraOAuth = !!(originalConfig.access_token || originalConfig._token_encrypted); + + if (isJiraOAuth) { + // OAuth connectors don't allow editing credentials through the form + // Only allow name changes, which are handled separately + break; + } + + // Legacy API token connector - allow editing credentials if ( formData.JIRA_BASE_URL !== originalConfig.JIRA_BASE_URL || formData.JIRA_EMAIL !== originalConfig.JIRA_EMAIL || @@ -464,6 +474,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) }; } break; + } case "LUMA_CONNECTOR": if (formData.LUMA_API_KEY !== originalConfig.LUMA_API_KEY) { if (!formData.LUMA_API_KEY) {