diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 3ee063e15..83656a910 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -39,16 +39,21 @@ GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/c GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback -# Notion OAuth for Notion Connector -NOTION_CLIENT_ID=your_notion_client_id -NOTION_CLIENT_SECRET=your_notion_client_secret -NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback - -# Airtable OAuth for Aitable Connector +# OAuth for Aitable Connector AIRTABLE_CLIENT_ID=your_airtable_client_id AIRTABLE_CLIENT_SECRET=your_airtable_client_secret AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback +# OAuth for Linear Connector +LINEAR_CLIENT_ID=your_linear_client_id +LINEAR_CLIENT_SECRET=your_linear_client_secret +LINEAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/linear/connector/callback + +# OAuth for Notion Connector +NOTION_CLIENT_ID=your_notion_client_id +NOTION_CLIENT_SECRET=your_notion_client_secret +NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback + # Embedding Model # Examples: # # Get sentence transformers embeddings diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 61e150bf3..7c7703470 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -95,6 +95,11 @@ class Config: NOTION_CLIENT_SECRET = os.getenv("NOTION_CLIENT_SECRET") NOTION_REDIRECT_URI = os.getenv("NOTION_REDIRECT_URI") + # Linear OAuth + LINEAR_CLIENT_ID = os.getenv("LINEAR_CLIENT_ID") + LINEAR_CLIENT_SECRET = os.getenv("LINEAR_CLIENT_SECRET") + LINEAR_REDIRECT_URI = os.getenv("LINEAR_REDIRECT_URI") + # LLM instances are now managed per-user through the LLMConfig system # Legacy environment variables removed in favor of user-specific configurations diff --git a/surfsense_backend/app/connectors/linear_connector.py b/surfsense_backend/app/connectors/linear_connector.py index b4c54fda3..e073963a6 100644 --- a/surfsense_backend/app/connectors/linear_connector.py +++ b/surfsense_backend/app/connectors/linear_connector.py @@ -14,24 +14,24 @@ import requests class LinearConnector: """Class for retrieving issues and comments from Linear.""" - def __init__(self, token: str | None = None): + def __init__(self, access_token: str | None = None): """ Initialize the LinearConnector class. Args: - token: Linear API token (optional, can be set later with set_token) + access_token: Linear OAuth access token (optional, can be set later with set_token) """ - self.token = token + self.access_token = access_token self.api_url = "https://api.linear.app/graphql" - def set_token(self, token: str) -> None: + def set_token(self, access_token: str) -> None: """ - Set the Linear API token. + Set the Linear OAuth access token. Args: - token: Linear API token + access_token: Linear OAuth access token """ - self.token = token + self.access_token = access_token def get_headers(self) -> dict[str, str]: """ @@ -41,12 +41,12 @@ class LinearConnector: Dictionary of headers Raises: - ValueError: If no Linear token has been set + ValueError: If no Linear access token has been set """ - if not self.token: - raise ValueError("Linear token not initialized. Call set_token() first.") + if not self.access_token: + raise ValueError("Linear access token not initialized. Call set_token() first.") - return {"Content-Type": "application/json", "Authorization": self.token} + return {"Content-Type": "application/json", "Authorization": f"Bearer {self.access_token}"} def execute_graphql_query( self, query: str, variables: dict[str, Any] | None = None @@ -62,11 +62,11 @@ class LinearConnector: Response data from the API Raises: - ValueError: If no Linear token has been set + ValueError: If no Linear access token has been set Exception: If the API request fails """ - if not self.token: - raise ValueError("Linear token not initialized. Call set_token() first.") + if not self.access_token: + raise ValueError("Linear access token not initialized. Call set_token() first.") headers = self.get_headers() payload = {"query": query} @@ -94,7 +94,7 @@ class LinearConnector: List of issue objects Raises: - ValueError: If no Linear token has been set + ValueError: If no Linear access token has been set Exception: If the API request fails """ comments_query = "" @@ -451,10 +451,10 @@ class LinearConnector: # Example usage (uncomment to use): """ if __name__ == "__main__": - # Set your token here - token = "YOUR_LINEAR_API_KEY" + # Set your OAuth access token here + access_token = "YOUR_LINEAR_ACCESS_TOKEN" - linear = LinearConnector(token) + linear = LinearConnector(access_token=access_token) try: # Get all issues with comments diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 1246dfe39..922f8834b 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -18,6 +18,7 @@ from .google_gmail_add_connector_route import ( from .logs_routes import router as logs_router from .luma_add_connector_route import router as luma_add_connector_router from .new_chat_routes import router as new_chat_router +from .linear_add_connector_route import router as linear_add_connector_router from .notion_add_connector_route import router as notion_add_connector_router from .new_llm_config_routes import router as new_llm_config_router from .notes_routes import router as notes_router @@ -40,6 +41,7 @@ router.include_router(google_calendar_add_connector_router) router.include_router(google_gmail_add_connector_router) router.include_router(google_drive_add_connector_router) router.include_router(airtable_add_connector_router) +router.include_router(linear_add_connector_router) router.include_router(luma_add_connector_router) router.include_router(notion_add_connector_router) router.include_router(new_llm_config_router) # LLM configs with prompt configuration diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py new file mode 100644 index 000000000..2aacd5b3a --- /dev/null +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -0,0 +1,256 @@ +""" +Linear Connector OAuth Routes. + +Handles OAuth 2.0 authentication flow for Linear connector. +""" + +import base64 +import json +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.users import current_active_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Linear OAuth endpoints +AUTHORIZATION_URL = "https://linear.app/oauth/authorize" +TOKEN_URL = "https://api.linear.app/oauth/token" + +# OAuth scopes for Linear +SCOPES = ["read", "write"] + + +def make_basic_auth_header(client_id: str, client_secret: str) -> str: + """Create Basic Auth header for Linear OAuth.""" + credentials = f"{client_id}:{client_secret}".encode() + b64 = base64.b64encode(credentials).decode("ascii") + return f"Basic {b64}" + + +@router.get("/auth/linear/connector/add") +async def connect_linear(space_id: int, user: User = Depends(current_active_user)): + """ + Initiate Linear 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.LINEAR_CLIENT_ID: + raise HTTPException( + status_code=500, detail="Linear OAuth not configured." + ) + + # Generate state parameter + state_payload = json.dumps( + { + "space_id": space_id, + "user_id": str(user.id), + } + ) + state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode() + + # Build authorization URL + from urllib.parse import urlencode + + auth_params = { + "client_id": config.LINEAR_CLIENT_ID, + "response_type": "code", + "redirect_uri": config.LINEAR_REDIRECT_URI, + "scope": " ".join(SCOPES), + "state": state_encoded, + } + + auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}" + + logger.info( + f"Generated Linear OAuth URL for user {user.id}, space {space_id}" + ) + return {"auth_url": auth_url} + + except Exception as e: + logger.error(f"Failed to initiate Linear OAuth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to initiate Linear OAuth: {e!s}" + ) from e + + +@router.get("/auth/linear/connector/callback") +async def linear_callback( + request: Request, + code: str, + state: str, + session: AsyncSession = Depends(get_async_session), +): + """ + Handle Linear OAuth callback. + + Args: + request: FastAPI request object + code: Authorization code from Linear + state: State parameter containing user/space info + session: Database session + + Returns: + Redirect response to frontend + """ + try: + # Decode and parse the state + try: + decoded_state = base64.urlsafe_b64decode(state.encode()).decode() + data = json.loads(decoded_state) + 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"] + + # Exchange authorization code for access token + auth_header = make_basic_auth_header( + config.LINEAR_CLIENT_ID, config.LINEAR_CLIENT_SECRET + ) + + token_data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": config.LINEAR_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", + "Authorization": auth_header, + }, + 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_detail) + except Exception: + pass + raise HTTPException( + status_code=400, detail=f"Token exchange failed: {error_detail}" + ) + + token_json = token_response.json() + + # Calculate expiration time (UTC, tz-aware) + expires_at = None + if token_json.get("expires_in"): + now_utc = datetime.now(UTC) + expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"])) + + # Store the access token and refresh token in connector config + connector_config = { + "access_token": token_json["access_token"], + "refresh_token": token_json.get("refresh_token"), + "token_type": token_json.get("token_type", "Bearer"), + "expires_in": token_json.get("expires_in"), + "expires_at": expires_at.isoformat() if expires_at else None, + "scope": token_json.get("scope"), + } + + # Check if connector already exists for this search space and user + existing_connector_result = await session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.search_space_id == space_id, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_CONNECTOR, + ) + ) + existing_connector = existing_connector_result.scalars().first() + + if existing_connector: + # Update existing connector + existing_connector.config = connector_config + existing_connector.name = "Linear Connector" + existing_connector.is_indexable = True + logger.info( + f"Updated existing Linear connector for user {user_id} in space {space_id}" + ) + else: + # Create new connector + new_connector = SearchSourceConnector( + name="Linear Connector", + connector_type=SearchSourceConnectorType.LINEAR_CONNECTOR, + is_indexable=True, + config=connector_config, + search_space_id=space_id, + user_id=user_id, + ) + session.add(new_connector) + logger.info( + f"Created new Linear connector for user {user_id} in space {space_id}" + ) + + try: + await session.commit() + logger.info(f"Successfully saved Linear 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=linear-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 Linear OAuth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to complete Linear OAuth: {e!s}" + ) from e + diff --git a/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py b/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py index afc9ffd3b..cea8ca645 100644 --- a/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/linear_indexer.py @@ -92,16 +92,16 @@ async def index_linear_issues( f"Connector with ID {connector_id} not found or is not a Linear connector", ) - # Get the Linear token from the connector config - linear_token = connector.config.get("LINEAR_API_KEY") - if not linear_token: + # Get the Linear access token from the connector config + linear_access_token = connector.config.get("access_token") + if not linear_access_token: await task_logger.log_task_failure( log_entry, - f"Linear API token not found in connector config for connector {connector_id}", - "Missing Linear token", + f"Linear access token not found in connector config for connector {connector_id}", + "Missing Linear access token", {"error_type": "MissingToken"}, ) - return 0, "Linear API token not found in connector config" + return 0, "Linear access token not found in connector config" # Initialize Linear client await task_logger.log_task_progress( @@ -110,7 +110,7 @@ async def index_linear_issues( {"stage": "client_initialization"}, ) - linear_client = LinearConnector(token=linear_token) + linear_client = LinearConnector(access_token=linear_access_token) # Calculate date range start_date_str, end_date_str = calculate_date_range( diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index 1e76afc67..125c73d7b 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -532,7 +532,6 @@ def validate_connector_config( ) }, }, - "LINEAR_CONNECTOR": {"required": ["LINEAR_API_KEY"], "validators": {}}, "DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}}, "JIRA_CONNECTOR": { "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx index f1a06de81..34e1ae2e9 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx @@ -63,7 +63,7 @@ export const ConnectorDialogHeader: FC = ({
- + = ({