Merge pull request #664 from AnishSarkar22/feat/slack-oauth

feat: Slack, Discord OAuth connector & 5 min periodic syncing
This commit is contained in:
Rohan Verma 2026-01-05 21:31:54 -08:00 committed by GitHub
commit afe63943f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 4622 additions and 2532 deletions

View file

@ -38,13 +38,33 @@ GOOGLE_OAUTH_CLIENT_SECRET=GOCSV
GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback
GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback 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 GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback
GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback
# Airtable OAuth for Aitable Connector # OAuth for Aitable Connector
AIRTABLE_CLIENT_ID=your_airtable_client_id AIRTABLE_CLIENT_ID=your_airtable_client_id
AIRTABLE_CLIENT_SECRET=your_airtable_client_secret AIRTABLE_CLIENT_SECRET=your_airtable_client_secret
AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback
# Discord OAuth Configuration
DISCORD_CLIENT_ID=your_discord_client_id_here
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
# 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
# OAuth for Slack connector
SLACK_CLIENT_ID=1234567890.1234567890123
SLACK_CLIENT_SECRET=abcdefghijklmnopqrstuvwxyz1234567890
SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback
# Embedding Model # Embedding Model
# Examples: # Examples:
# # Get sentence transformers embeddings # # Get sentence transformers embeddings

View file

@ -90,6 +90,27 @@ class Config:
AIRTABLE_CLIENT_SECRET = os.getenv("AIRTABLE_CLIENT_SECRET") AIRTABLE_CLIENT_SECRET = os.getenv("AIRTABLE_CLIENT_SECRET")
AIRTABLE_REDIRECT_URI = os.getenv("AIRTABLE_REDIRECT_URI") AIRTABLE_REDIRECT_URI = os.getenv("AIRTABLE_REDIRECT_URI")
# Notion OAuth
NOTION_CLIENT_ID = os.getenv("NOTION_CLIENT_ID")
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")
# Slack OAuth
SLACK_CLIENT_ID = os.getenv("SLACK_CLIENT_ID")
SLACK_CLIENT_SECRET = os.getenv("SLACK_CLIENT_SECRET")
SLACK_REDIRECT_URI = os.getenv("SLACK_REDIRECT_URI")
# Discord OAuth
DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID")
DISCORD_CLIENT_SECRET = os.getenv("DISCORD_CLIENT_SECRET")
DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI")
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
# LLM instances are now managed per-user through the LLMConfig system # LLM instances are now managed per-user through the LLMConfig system
# Legacy environment variables removed in favor of user-specific configurations # Legacy environment variables removed in favor of user-specific configurations

View file

@ -3,7 +3,7 @@ Discord Connector
A module for interacting with Discord's HTTP API to retrieve guilds, channels, and message history. A module for interacting with Discord's HTTP API to retrieve guilds, channels, and message history.
Requires a Discord bot token. Supports both direct bot token and OAuth-based authentication with token refresh.
""" """
import asyncio import asyncio
@ -12,6 +12,14 @@ import logging
import discord import discord
from discord.ext import commands from discord.ext import commands
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import SearchSourceConnector
from app.routes.discord_add_connector_route import refresh_discord_token
from app.schemas.discord_auth_credentials import DiscordAuthCredentialsBase
from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -19,12 +27,21 @@ logger = logging.getLogger(__name__)
class DiscordConnector(commands.Bot): class DiscordConnector(commands.Bot):
"""Class for retrieving guild, channel, and message history from Discord.""" """Class for retrieving guild, channel, and message history from Discord."""
def __init__(self, token: str | None = None): def __init__(
self,
token: str | None = None,
session: AsyncSession | None = None,
connector_id: int | None = None,
credentials: DiscordAuthCredentialsBase | None = None,
):
""" """
Initialize the DiscordConnector with a bot token. Initialize the DiscordConnector with a bot token or OAuth credentials.
Args: Args:
token (str): The Discord bot token. token: Discord bot token (optional, for backward compatibility)
session: Database session for token refresh (optional)
connector_id: Connector ID for token refresh (optional)
credentials: Discord OAuth credentials (optional, will be loaded from DB if not provided)
""" """
intents = discord.Intents.default() intents = discord.Intents.default()
intents.guilds = True # Required to fetch guilds and channels intents.guilds = True # Required to fetch guilds and channels
@ -34,7 +51,14 @@ class DiscordConnector(commands.Bot):
super().__init__( super().__init__(
command_prefix="!", intents=intents command_prefix="!", intents=intents
) # command_prefix is required but not strictly used here ) # command_prefix is required but not strictly used here
self._session = session
self._connector_id = connector_id
self._credentials = credentials
# For backward compatibility, if token is provided directly, use it
if token:
self.token = token self.token = token
else:
self.token = None
self._bot_task = None # Holds the async bot task self._bot_task = None # Holds the async bot task
self._is_running = False # Flag to track if the bot is running self._is_running = False # Flag to track if the bot is running
@ -57,12 +81,143 @@ class DiscordConnector(commands.Bot):
async def on_resumed(): async def on_resumed():
logger.debug("Bot resumed connection to Discord gateway.") logger.debug("Bot resumed connection to Discord gateway.")
async def _get_valid_token(self) -> str:
"""
Get valid Discord bot token, refreshing if needed.
Returns:
Valid bot token
Raises:
ValueError: If credentials are missing or invalid
Exception: If token refresh fails
"""
# If we have a direct token (backward compatibility), use it
if (
self.token
and self._session is None
and self._connector_id is None
and self._credentials is None
):
# This means it was initialized with a direct token, use it
return self.token
# Load credentials from DB if not provided
if self._credentials is None:
if not self._session or not self._connector_id:
raise ValueError(
"Cannot load credentials: session and connector_id required"
)
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()
# 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("bot_token"):
config_data["bot_token"] = token_encryption.decrypt_token(
config_data["bot_token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
logger.info(
f"Decrypted Discord credentials for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to decrypt Discord credentials for connector {self._connector_id}: {e!s}"
)
raise ValueError(
f"Failed to decrypt Discord credentials: {e!s}"
) from e
try:
self._credentials = DiscordAuthCredentialsBase.from_dict(config_data)
except Exception as e:
raise ValueError(f"Invalid Discord credentials: {e!s}") from e
# Check if token is expired and refreshable
if self._credentials.is_expired and self._credentials.is_refreshable:
try:
logger.info(
f"Discord 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_discord_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("bot_token"):
config_data["bot_token"] = token_encryption.decrypt_token(
config_data["bot_token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
self._credentials = DiscordAuthCredentialsBase.from_dict(config_data)
logger.info(
f"Successfully refreshed Discord token for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to refresh Discord token for connector {self._connector_id}: {e!s}"
)
raise Exception(
f"Failed to refresh Discord OAuth credentials: {e!s}"
) from e
return self._credentials.bot_token
async def start_bot(self): async def start_bot(self):
"""Starts the bot to connect to Discord.""" """Starts the bot to connect to Discord."""
logger.info("Starting Discord bot...") logger.info("Starting Discord bot...")
# Get valid token (with auto-refresh if using OAuth)
if not self.token: if not self.token:
raise ValueError("Discord bot token not set. Call set_token(token) first.") # Try to get token from credentials
try:
self.token = await self._get_valid_token()
except ValueError as e:
raise ValueError(
f"Discord bot token not set. {e!s} Please authenticate via OAuth or provide a token."
) from e
try: try:
if self._is_running: if self._is_running:
@ -107,7 +262,7 @@ class DiscordConnector(commands.Bot):
def set_token(self, token: str) -> None: def set_token(self, token: str) -> None:
""" """
Set the discord bot token. Set the discord bot token (for backward compatibility).
Args: Args:
token (str): The Discord bot token. token (str): The Discord bot token.

View file

@ -109,7 +109,36 @@ class GoogleCalendarConnector:
raise RuntimeError( raise RuntimeError(
"GOOGLE_CALENDAR_CONNECTOR connector not found; cannot persist refreshed token." "GOOGLE_CALENDAR_CONNECTOR connector not found; cannot persist refreshed token."
) )
connector.config = json.loads(self._credentials.to_json())
# Encrypt sensitive credentials before storing
from app.config import config
from app.utils.oauth_security import TokenEncryption
creds_dict = json.loads(self._credentials.to_json())
token_encrypted = connector.config.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
# Encrypt sensitive fields
if creds_dict.get("token"):
creds_dict["token"] = token_encryption.encrypt_token(
creds_dict["token"]
)
if creds_dict.get("refresh_token"):
creds_dict["refresh_token"] = (
token_encryption.encrypt_token(
creds_dict["refresh_token"]
)
)
if creds_dict.get("client_secret"):
creds_dict["client_secret"] = (
token_encryption.encrypt_token(
creds_dict["client_secret"]
)
)
creds_dict["_token_encrypted"] = True
connector.config = creds_dict
flag_modified(connector, "config") flag_modified(connector, "config")
await self._session.commit() await self._session.commit()
except Exception as e: except Exception as e:
@ -182,6 +211,18 @@ class GoogleCalendarConnector:
Tuple containing (events list, error message or None) Tuple containing (events list, error message or None)
""" """
try: try:
# Validate date strings
if not start_date or start_date.lower() in ("undefined", "null", "none"):
return (
[],
"Invalid start_date: must be a valid date string in YYYY-MM-DD format",
)
if not end_date or end_date.lower() in ("undefined", "null", "none"):
return (
[],
"Invalid end_date: must be a valid date string in YYYY-MM-DD format",
)
service = await self._get_service() service = await self._get_service()
# Parse both dates # Parse both dates

View file

@ -1,6 +1,7 @@
"""Google Drive OAuth credential management.""" """Google Drive OAuth credential management."""
import json import json
import logging
from datetime import datetime from datetime import datetime
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
@ -9,7 +10,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified from sqlalchemy.orm.attributes import flag_modified
from app.config import config
from app.db import SearchSourceConnector from app.db import SearchSourceConnector
from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__)
async def get_valid_credentials( async def get_valid_credentials(
@ -38,7 +43,41 @@ async def get_valid_credentials(
if not connector: if not connector:
raise ValueError(f"Connector {connector_id} not found") raise ValueError(f"Connector {connector_id} not found")
config_data = connector.config config_data = (
connector.config.copy()
) # Work with a copy to avoid modifying original
# 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("token"):
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
logger.info(
f"Decrypted Google Drive credentials for connector {connector_id}"
)
except Exception as e:
logger.error(
f"Failed to decrypt Google Drive credentials for connector {connector_id}: {e!s}"
)
raise ValueError(
f"Failed to decrypt Google Drive credentials: {e!s}"
) from e
exp = config_data.get("expiry", "").replace("Z", "") exp = config_data.get("expiry", "").replace("Z", "")
if not all( if not all(
@ -66,7 +105,29 @@ async def get_valid_credentials(
try: try:
credentials.refresh(Request()) credentials.refresh(Request())
connector.config = json.loads(credentials.to_json()) creds_dict = json.loads(credentials.to_json())
# Encrypt sensitive credentials before storing
token_encrypted = connector.config.get("_token_encrypted", False)
if token_encrypted and config.SECRET_KEY:
token_encryption = TokenEncryption(config.SECRET_KEY)
# Encrypt sensitive fields
if creds_dict.get("token"):
creds_dict["token"] = token_encryption.encrypt_token(
creds_dict["token"]
)
if creds_dict.get("refresh_token"):
creds_dict["refresh_token"] = token_encryption.encrypt_token(
creds_dict["refresh_token"]
)
if creds_dict.get("client_secret"):
creds_dict["client_secret"] = token_encryption.encrypt_token(
creds_dict["client_secret"]
)
creds_dict["_token_encrypted"] = True
connector.config = creds_dict
flag_modified(connector, "config") flag_modified(connector, "config")
await session.commit() await session.commit()

View file

@ -5,33 +5,153 @@ A module for retrieving issues and comments from Linear.
Allows fetching issue lists and their comments with date range filtering. Allows fetching issue lists and their comments with date range filtering.
""" """
import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
import requests import requests
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import SearchSourceConnector
from app.routes.linear_add_connector_route import refresh_linear_token
from app.schemas.linear_auth_credentials import LinearAuthCredentialsBase
from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__)
class LinearConnector: class LinearConnector:
"""Class for retrieving issues and comments from Linear.""" """Class for retrieving issues and comments from Linear."""
def __init__(self, token: str | None = None): def __init__(
self,
session: AsyncSession,
connector_id: int,
credentials: LinearAuthCredentialsBase | None = None,
):
""" """
Initialize the LinearConnector class. Initialize the LinearConnector class with auto-refresh capability.
Args: Args:
token: Linear API token (optional, can be set later with set_token) session: Database session for updating connector
connector_id: Connector ID for direct updates
credentials: Linear OAuth credentials (optional, will be loaded from DB if not provided)
""" """
self.token = token self._session = session
self._connector_id = connector_id
self._credentials = credentials
self.api_url = "https://api.linear.app/graphql" self.api_url = "https://api.linear.app/graphql"
def set_token(self, token: str) -> None: async def _get_valid_token(self) -> str:
""" """
Set the Linear API token. Get valid Linear access token, refreshing if needed.
Args: Returns:
token: Linear API token Valid access token
Raises:
ValueError: If credentials are missing or invalid
Exception: If token refresh fails
""" """
self.token = token # 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()
# 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 Linear credentials for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to decrypt Linear credentials for connector {self._connector_id}: {e!s}"
)
raise ValueError(
f"Failed to decrypt Linear credentials: {e!s}"
) from e
try:
self._credentials = LinearAuthCredentialsBase.from_dict(config_data)
except Exception as e:
raise ValueError(f"Invalid Linear credentials: {e!s}") from e
# Check if token is expired and refreshable
if self._credentials.is_expired and self._credentials.is_refreshable:
try:
logger.info(
f"Linear 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_linear_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 = LinearAuthCredentialsBase.from_dict(config_data)
logger.info(
f"Successfully refreshed Linear token for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to refresh Linear token for connector {self._connector_id}: {e!s}"
)
raise Exception(
f"Failed to refresh Linear OAuth credentials: {e!s}"
) from e
return self._credentials.access_token
def get_headers(self) -> dict[str, str]: def get_headers(self) -> dict[str, str]:
""" """
@ -41,18 +161,26 @@ class LinearConnector:
Dictionary of headers Dictionary of headers
Raises: Raises:
ValueError: If no Linear token has been set ValueError: If no Linear access token has been set
""" """
if not self.token: # This is a synchronous method, but we need async token refresh
raise ValueError("Linear token not initialized. Call set_token() first.") # For now, we'll raise an error if called directly
# All API calls should go through execute_graphql_query which handles async refresh
if not self._credentials or not self._credentials.access_token:
raise ValueError(
"Linear access token not initialized. Use execute_graphql_query() method."
)
return {"Content-Type": "application/json", "Authorization": self.token} return {
"Content-Type": "application/json",
"Authorization": f"Bearer {self._credentials.access_token}",
}
def execute_graphql_query( async def execute_graphql_query(
self, query: str, variables: dict[str, Any] | None = None self, query: str, variables: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
Execute a GraphQL query against the Linear API. Execute a GraphQL query against the Linear API with automatic token refresh.
Args: Args:
query: GraphQL query string query: GraphQL query string
@ -62,13 +190,17 @@ class LinearConnector:
Response data from the API Response data from the API
Raises: Raises:
ValueError: If no Linear token has been set ValueError: If no Linear access token has been set
Exception: If the API request fails Exception: If the API request fails
""" """
if not self.token: # Get valid token (refreshes if needed)
raise ValueError("Linear token not initialized. Call set_token() first.") access_token = await self._get_valid_token()
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
}
headers = self.get_headers()
payload = {"query": query} payload = {"query": query}
if variables: if variables:
@ -83,7 +215,9 @@ class LinearConnector:
f"Query failed with status code {response.status_code}: {response.text}" f"Query failed with status code {response.status_code}: {response.text}"
) )
def get_all_issues(self, include_comments: bool = True) -> list[dict[str, Any]]: async def get_all_issues(
self, include_comments: bool = True
) -> list[dict[str, Any]]:
""" """
Fetch all issues from Linear. Fetch all issues from Linear.
@ -94,7 +228,7 @@ class LinearConnector:
List of issue objects List of issue objects
Raises: Raises:
ValueError: If no Linear token has been set ValueError: If no Linear access token has been set
Exception: If the API request fails Exception: If the API request fails
""" """
comments_query = "" comments_query = ""
@ -146,7 +280,7 @@ class LinearConnector:
}} }}
""" """
result = self.execute_graphql_query(query) result = await self.execute_graphql_query(query)
# Extract issues from the response # Extract issues from the response
if ( if (
@ -158,7 +292,7 @@ class LinearConnector:
return [] return []
def get_issues_by_date_range( async def get_issues_by_date_range(
self, start_date: str, end_date: str, include_comments: bool = True self, start_date: str, end_date: str, include_comments: bool = True
) -> tuple[list[dict[str, Any]], str | None]: ) -> tuple[list[dict[str, Any]], str | None]:
""" """
@ -172,6 +306,18 @@ class LinearConnector:
Returns: Returns:
Tuple containing (issues list, error message or None) Tuple containing (issues list, error message or None)
""" """
# Validate date strings
if not start_date or start_date.lower() in ("undefined", "null", "none"):
return (
[],
"Invalid start_date: must be a valid date string in YYYY-MM-DD format",
)
if not end_date or end_date.lower() in ("undefined", "null", "none"):
return (
[],
"Invalid end_date: must be a valid date string in YYYY-MM-DD format",
)
# Convert date strings to ISO format # Convert date strings to ISO format
try: try:
# For Linear API: we need to use a more specific format for the filter # For Linear API: we need to use a more specific format for the filter
@ -258,7 +404,7 @@ class LinearConnector:
# Handle pagination to get all issues # Handle pagination to get all issues
while has_next_page: while has_next_page:
variables = {"after": cursor} if cursor else {} variables = {"after": cursor} if cursor else {}
result = self.execute_graphql_query(query, variables) result = await self.execute_graphql_query(query, variables)
# Check for errors # Check for errors
if "errors" in result: if "errors" in result:
@ -446,37 +592,3 @@ class LinearConnector:
return dt.strftime("%Y-%m-%d %H:%M:%S") return dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError: except ValueError:
return iso_date return iso_date
# Example usage (uncomment to use):
"""
if __name__ == "__main__":
# Set your token here
token = "YOUR_LINEAR_API_KEY"
linear = LinearConnector(token)
try:
# Get all issues with comments
issues = linear.get_all_issues()
print(f"Retrieved {len(issues)} issues")
# Format and print the first issue as markdown
if issues:
issue_md = linear.format_issue_to_markdown(issues[0])
print("\nSample Issue in Markdown:\n")
print(issue_md)
# Get issues by date range
start_date = "2023-01-01"
end_date = "2023-01-31"
date_issues, error = linear.get_issues_by_date_range(start_date, end_date)
if error:
print(f"Error: {error}")
else:
print(f"\nRetrieved {len(date_issues)} issues from {start_date} to {end_date}")
except Exception as e:
print(f"Error: {e}")
"""

View file

@ -1,19 +1,167 @@
import logging
from notion_client import AsyncClient from notion_client import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import SearchSourceConnector
from app.routes.notion_add_connector_route import refresh_notion_token
from app.schemas.notion_auth_credentials import NotionAuthCredentialsBase
from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__)
class NotionHistoryConnector: class NotionHistoryConnector:
def __init__(self, token): def __init__(
self,
session: AsyncSession,
connector_id: int,
credentials: NotionAuthCredentialsBase | None = None,
):
""" """
Initialize the NotionPageFetcher with a token. Initialize the NotionHistoryConnector with auto-refresh capability.
Args: Args:
token (str): Notion integration token session: Database session for updating connector
connector_id: Connector ID for direct updates
credentials: Notion OAuth credentials (optional, will be loaded from DB if not provided)
""" """
self.notion = AsyncClient(auth=token) self._session = session
self._connector_id = connector_id
self._credentials = credentials
self._notion_client: AsyncClient | None = None
async def _get_valid_token(self) -> str:
"""
Get valid Notion 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()
# 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 Notion credentials for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to decrypt Notion credentials for connector {self._connector_id}: {e!s}"
)
raise ValueError(
f"Failed to decrypt Notion credentials: {e!s}"
) from e
try:
self._credentials = NotionAuthCredentialsBase.from_dict(config_data)
except Exception as e:
raise ValueError(f"Invalid Notion credentials: {e!s}") from e
# Check if token is expired and refreshable
if self._credentials.is_expired and self._credentials.is_refreshable:
try:
logger.info(
f"Notion 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_notion_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 = NotionAuthCredentialsBase.from_dict(config_data)
# Invalidate cached client so it's recreated with new token
self._notion_client = None
logger.info(
f"Successfully refreshed Notion token for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to refresh Notion token for connector {self._connector_id}: {e!s}"
)
raise Exception(
f"Failed to refresh Notion OAuth credentials: {e!s}"
) from e
return self._credentials.access_token
async def _get_client(self) -> AsyncClient:
"""
Get or create Notion AsyncClient with valid token.
Returns:
Notion AsyncClient instance
"""
if self._notion_client is None:
token = await self._get_valid_token()
self._notion_client = AsyncClient(auth=token)
return self._notion_client
async def close(self): async def close(self):
"""Close the async client connection.""" """Close the async client connection."""
await self.notion.aclose() if self._notion_client:
await self._notion_client.aclose()
self._notion_client = None
async def __aenter__(self): async def __aenter__(self):
"""Async context manager entry.""" """Async context manager entry."""
@ -34,6 +182,8 @@ class NotionHistoryConnector:
Returns: Returns:
list: List of dictionaries containing page data list: List of dictionaries containing page data
""" """
notion = await self._get_client()
# Build the filter for the search # Build the filter for the search
# Note: Notion API requires specific filter structure # Note: Notion API requires specific filter structure
search_params = {} search_params = {}
@ -67,7 +217,7 @@ class NotionHistoryConnector:
if cursor: if cursor:
search_params["start_cursor"] = cursor search_params["start_cursor"] = cursor
search_results = await self.notion.search(**search_params) search_results = await notion.search(**search_params)
pages.extend(search_results["results"]) pages.extend(search_results["results"])
has_more = search_results.get("has_more", False) has_more = search_results.get("has_more", False)
@ -125,6 +275,8 @@ class NotionHistoryConnector:
Returns: Returns:
list: List of processed blocks from the page list: List of processed blocks from the page
""" """
notion = await self._get_client()
blocks = [] blocks = []
has_more = True has_more = True
cursor = None cursor = None
@ -132,11 +284,11 @@ class NotionHistoryConnector:
# Paginate through all blocks # Paginate through all blocks
while has_more: while has_more:
if cursor: if cursor:
response = await self.notion.blocks.children.list( response = await notion.blocks.children.list(
block_id=page_id, start_cursor=cursor block_id=page_id, start_cursor=cursor
) )
else: else:
response = await self.notion.blocks.children.list(block_id=page_id) response = await notion.blocks.children.list(block_id=page_id)
blocks.extend(response["results"]) blocks.extend(response["results"])
has_more = response["has_more"] has_more = response["has_more"]
@ -162,6 +314,8 @@ class NotionHistoryConnector:
Returns: Returns:
dict: Processed block with content and children dict: Processed block with content and children
""" """
notion = await self._get_client()
block_id = block["id"] block_id = block["id"]
block_type = block["type"] block_type = block["type"]
@ -174,9 +328,7 @@ class NotionHistoryConnector:
if has_children: if has_children:
# Fetch and process child blocks # Fetch and process child blocks
children_response = await self.notion.blocks.children.list( children_response = await notion.blocks.children.list(block_id=block_id)
block_id=block_id
)
for child_block in children_response["results"]: for child_block in children_response["results"]:
child_blocks.append(await self.process_block(child_block)) child_blocks.append(await self.process_block(child_block))

View file

@ -12,6 +12,14 @@ from typing import Any
from slack_sdk import WebClient from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError from slack_sdk.errors import SlackApiError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.db import SearchSourceConnector
from app.routes.slack_add_connector_route import refresh_slack_token
from app.schemas.slack_auth_credentials import SlackAuthCredentialsBase
from app.utils.oauth_security import TokenEncryption
logger = logging.getLogger(__name__) # Added logger logger = logging.getLogger(__name__) # Added logger
@ -19,25 +27,199 @@ logger = logging.getLogger(__name__) # Added logger
class SlackHistory: class SlackHistory:
"""Class for retrieving conversation history from Slack channels.""" """Class for retrieving conversation history from Slack channels."""
def __init__(self, token: str | None = None): def __init__(
self,
token: str | None = None,
session: AsyncSession | None = None,
connector_id: int | None = None,
credentials: SlackAuthCredentialsBase | None = None,
):
""" """
Initialize the SlackHistory class. Initialize the SlackHistory class.
Args: Args:
token: Slack API token (optional, can be set later with set_token) token: Slack API token (optional, for backward compatibility)
session: Database session for token refresh (optional)
connector_id: Connector ID for token refresh (optional)
credentials: Slack OAuth credentials (optional, will be loaded from DB if not provided)
""" """
self.client = WebClient(token=token) if token else None self._session = session
self._connector_id = connector_id
self._credentials = credentials
# For backward compatibility, if token is provided directly, use it
if token:
self.client = WebClient(token=token)
else:
self.client = None
async def _get_valid_token(self) -> str:
"""
Get valid Slack bot token, refreshing if needed.
Returns:
Valid bot token
Raises:
ValueError: If credentials are missing or invalid
Exception: If token refresh fails
"""
# If we have a direct token (backward compatibility), use it
# Check if client was initialized with a token directly (not via credentials)
if (
self.client
and self._session is None
and self._connector_id is None
and self._credentials is None
):
# This means it was initialized with a direct token, extract it
# WebClient stores token internally, we need to get it from the client
# For backward compatibility, we'll use the client directly
# But we can't easily extract the token, so we'll just use the client
# In this case, we'll skip refresh logic
# This is the old pattern - just use the client as-is
# We can't extract token easily, so we'll raise an error
# asking to use the new pattern
raise ValueError(
"Cannot refresh token: Please use session and connector_id for auto-refresh support"
)
# Load credentials from DB if not provided
if self._credentials is None:
if not self._session or not self._connector_id:
raise ValueError(
"Cannot load credentials: session and connector_id required"
)
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()
# 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("bot_token"):
config_data["bot_token"] = token_encryption.decrypt_token(
config_data["bot_token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
logger.info(
f"Decrypted Slack credentials for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to decrypt Slack credentials for connector {self._connector_id}: {e!s}"
)
raise ValueError(
f"Failed to decrypt Slack credentials: {e!s}"
) from e
try:
self._credentials = SlackAuthCredentialsBase.from_dict(config_data)
except Exception as e:
raise ValueError(f"Invalid Slack credentials: {e!s}") from e
# Check if token is expired and refreshable
if self._credentials.is_expired and self._credentials.is_refreshable:
try:
logger.info(
f"Slack 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_slack_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("bot_token"):
config_data["bot_token"] = token_encryption.decrypt_token(
config_data["bot_token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
self._credentials = SlackAuthCredentialsBase.from_dict(config_data)
# Invalidate cached client so it's recreated with new token
self.client = None
logger.info(
f"Successfully refreshed Slack token for connector {self._connector_id}"
)
except Exception as e:
logger.error(
f"Failed to refresh Slack token for connector {self._connector_id}: {e!s}"
)
raise Exception(
f"Failed to refresh Slack OAuth credentials: {e!s}"
) from e
return self._credentials.bot_token
async def _ensure_client(self) -> WebClient:
"""
Ensure Slack client is initialized with valid token.
Returns:
WebClient instance
"""
# If client was initialized with direct token (backward compatibility), use it
if self.client and (self._session is None or self._connector_id is None):
return self.client
# Otherwise, initialize with token from credentials (with auto-refresh)
if self.client is None:
token = await self._get_valid_token()
# Skip if it's the placeholder for direct token initialization
if token != "direct_token_initialized":
self.client = WebClient(token=token)
return self.client
def set_token(self, token: str) -> None: def set_token(self, token: str) -> None:
""" """
Set the Slack API token. Set the Slack API token (for backward compatibility).
Args: Args:
token: Slack API token token: Slack API token
""" """
self.client = WebClient(token=token) self.client = WebClient(token=token)
def get_all_channels(self, include_private: bool = True) -> list[dict[str, Any]]: async def get_all_channels(
self, include_private: bool = True
) -> list[dict[str, Any]]:
""" """
Fetch all channels that the bot has access to, with rate limit handling. Fetch all channels that the bot has access to, with rate limit handling.
@ -52,8 +234,7 @@ class SlackHistory:
SlackApiError: If there's an unrecoverable error calling the Slack API SlackApiError: If there's an unrecoverable error calling the Slack API
RuntimeError: For unexpected errors during channel fetching. RuntimeError: For unexpected errors during channel fetching.
""" """
if not self.client: client = await self._ensure_client()
raise ValueError("Slack client not initialized. Call set_token() first.")
channels_list = [] # Changed from dict to list channels_list = [] # Changed from dict to list
types = "public_channel" types = "public_channel"
@ -72,7 +253,7 @@ class SlackHistory:
time.sleep(3) time.sleep(3)
current_limit = 1000 # Max limit current_limit = 1000 # Max limit
api_result = self.client.conversations_list( api_result = client.conversations_list(
types=types, cursor=next_cursor, limit=current_limit types=types, cursor=next_cursor, limit=current_limit
) )
@ -129,7 +310,7 @@ class SlackHistory:
return channels_list return channels_list
def get_conversation_history( async def get_conversation_history(
self, self,
channel_id: str, channel_id: str,
limit: int = 1000, limit: int = 1000,
@ -152,8 +333,7 @@ class SlackHistory:
ValueError: If no Slack client has been initialized ValueError: If no Slack client has been initialized
SlackApiError: If there's an error calling the Slack API SlackApiError: If there's an error calling the Slack API
""" """
if not self.client: client = await self._ensure_client()
raise ValueError("Slack client not initialized. Call set_token() first.")
messages = [] messages = []
next_cursor = None next_cursor = None
@ -177,7 +357,7 @@ class SlackHistory:
current_api_call_successful = False current_api_call_successful = False
result = None # Ensure result is defined result = None # Ensure result is defined
try: try:
result = self.client.conversations_history(**kwargs) result = client.conversations_history(**kwargs)
current_api_call_successful = True current_api_call_successful = True
except SlackApiError as e_history: except SlackApiError as e_history:
if ( if (
@ -252,7 +432,7 @@ class SlackHistory:
except ValueError: except ValueError:
return None return None
def get_history_by_date_range( async def get_history_by_date_range(
self, channel_id: str, start_date: str, end_date: str, limit: int = 1000 self, channel_id: str, start_date: str, end_date: str, limit: int = 1000
) -> tuple[list[dict[str, Any]], str | None]: ) -> tuple[list[dict[str, Any]], str | None]:
""" """
@ -282,7 +462,7 @@ class SlackHistory:
latest += 86400 # seconds in a day latest += 86400 # seconds in a day
try: try:
messages = self.get_conversation_history( messages = await self.get_conversation_history(
channel_id=channel_id, limit=limit, oldest=oldest, latest=latest channel_id=channel_id, limit=limit, oldest=oldest, latest=latest
) )
return messages, None return messages, None
@ -291,7 +471,7 @@ class SlackHistory:
except ValueError as e: except ValueError as e:
return [], str(e) return [], str(e)
def get_user_info(self, user_id: str) -> dict[str, Any]: async def get_user_info(self, user_id: str) -> dict[str, Any]:
""" """
Get information about a user. Get information about a user.
@ -305,8 +485,7 @@ class SlackHistory:
ValueError: If no Slack client has been initialized ValueError: If no Slack client has been initialized
SlackApiError: If there's an error calling the Slack API SlackApiError: If there's an error calling the Slack API
""" """
if not self.client: client = await self._ensure_client()
raise ValueError("Slack client not initialized. Call set_token() first.")
while True: while True:
try: try:
@ -314,7 +493,7 @@ class SlackHistory:
# For now, we are only adding Retry-After as per plan. # For now, we are only adding Retry-After as per plan.
# time.sleep(0.6) # Optional: ~100 req/min if ever needed. # time.sleep(0.6) # Optional: ~100 req/min if ever needed.
result = self.client.users_info(user=user_id) result = client.users_info(user=user_id)
return result["user"] # Success, return and exit loop implicitly return result["user"] # Success, return and exit loop implicitly
except SlackApiError as e_user_info: except SlackApiError as e_user_info:
@ -343,7 +522,7 @@ class SlackHistory:
) )
raise general_error from general_error # Re-raise unexpected errors raise general_error from general_error # Re-raise unexpected errors
def format_message( async def format_message(
self, msg: dict[str, Any], include_user_info: bool = False self, msg: dict[str, Any], include_user_info: bool = False
) -> dict[str, Any]: ) -> dict[str, Any]:
""" """
@ -369,9 +548,9 @@ class SlackHistory:
"is_thread": "thread_ts" in msg, "is_thread": "thread_ts" in msg,
} }
if include_user_info and "user" in msg and self.client: if include_user_info and "user" in msg:
try: try:
user_info = self.get_user_info(msg["user"]) user_info = await self.get_user_info(msg["user"])
formatted["user_name"] = user_info.get("real_name", "Unknown") formatted["user_name"] = user_info.get("real_name", "Unknown")
formatted["user_email"] = user_info.get("profile", {}).get("email", "") formatted["user_email"] = user_info.get("profile", {}).get("email", "")
except Exception: except Exception:

View file

@ -15,15 +15,19 @@ from .google_drive_add_connector_route import (
from .google_gmail_add_connector_route import ( from .google_gmail_add_connector_route import (
router as google_gmail_add_connector_router, router as google_gmail_add_connector_router,
) )
from .linear_add_connector_route import router as linear_add_connector_router
from .logs_routes import router as logs_router from .logs_routes import router as logs_router
from .luma_add_connector_route import router as luma_add_connector_router from .luma_add_connector_route import router as luma_add_connector_router
from .new_chat_routes import router as new_chat_router from .new_chat_routes import router as new_chat_router
from .new_llm_config_routes import router as new_llm_config_router from .new_llm_config_routes import router as new_llm_config_router
from .notes_routes import router as notes_router from .notes_routes import router as notes_router
from .notion_add_connector_route import router as notion_add_connector_router
from .podcasts_routes import router as podcasts_router from .podcasts_routes import router as podcasts_router
from .rbac_routes import router as rbac_router from .rbac_routes import router as rbac_router
from .search_source_connectors_routes import router as search_source_connectors_router from .search_source_connectors_routes import router as search_source_connectors_router
from .search_spaces_routes import router as search_spaces_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() router = APIRouter()
@ -39,7 +43,11 @@ router.include_router(google_calendar_add_connector_router)
router.include_router(google_gmail_add_connector_router) router.include_router(google_gmail_add_connector_router)
router.include_router(google_drive_add_connector_router) router.include_router(google_drive_add_connector_router)
router.include_router(airtable_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(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(new_llm_config_router) # LLM configs with prompt configuration router.include_router(new_llm_config_router) # LLM configs with prompt configuration
router.include_router(logs_router) router.include_router(logs_router)
router.include_router(circleback_webhook_router) # Circleback meeting webhooks router.include_router(circleback_webhook_router) # Circleback meeting webhooks

View file

@ -1,6 +1,5 @@
import base64 import base64
import hashlib import hashlib
import json
import logging import logging
import secrets import secrets
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
@ -23,6 +22,7 @@ from app.db import (
) )
from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase
from app.users import current_active_user from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,6 +40,30 @@ SCOPES = [
"user.email:read", "user.email:read",
] ]
# 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
def make_basic_auth_header(client_id: str, client_secret: str) -> str: def make_basic_auth_header(client_id: str, client_secret: str) -> str:
credentials = f"{client_id}:{client_secret}".encode() credentials = f"{client_id}:{client_secret}".encode()
@ -90,18 +114,19 @@ async def connect_airtable(space_id: int, user: User = Depends(current_active_us
status_code=500, detail="Airtable OAuth not configured." status_code=500, detail="Airtable OAuth not configured."
) )
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
# Generate PKCE parameters # Generate PKCE parameters
code_verifier, code_challenge = generate_pkce_pair() code_verifier, code_challenge = generate_pkce_pair()
# Generate state parameter # Generate secure state parameter with HMAC signature (including code_verifier for PKCE)
state_payload = json.dumps( state_manager = get_state_manager()
{ state_encoded = state_manager.generate_secure_state(
"space_id": space_id, space_id, user.id, code_verifier=code_verifier
"user_id": str(user.id),
"code_verifier": code_verifier,
}
) )
state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode()
# Build authorization URL # Build authorization URL
auth_params = { auth_params = {
@ -134,8 +159,9 @@ async def connect_airtable(space_id: int, user: User = Depends(current_active_us
@router.get("/auth/airtable/connector/callback") @router.get("/auth/airtable/connector/callback")
async def airtable_callback( async def airtable_callback(
request: Request, request: Request,
code: str, code: str | None = None,
state: str, error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
""" """
@ -143,7 +169,8 @@ async def airtable_callback(
Args: Args:
request: FastAPI request object request: FastAPI request object
code: Authorization code from Airtable code: Authorization code from Airtable (if user granted access)
error: Error code from Airtable (if user denied access or error occurred)
state: State parameter containing user/space info state: State parameter containing user/space info
session: Database session session: Database session
@ -151,10 +178,42 @@ async def airtable_callback(
Redirect response to frontend Redirect response to frontend
""" """
try: try:
# Decode and parse the state # Handle OAuth errors (e.g., user denied access)
if error:
logger.warning(f"Airtable 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: try:
decoded_state = base64.urlsafe_b64decode(state.encode()).decode() state_manager = get_state_manager()
data = json.loads(decoded_state) 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=airtable_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=airtable_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: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"Invalid state parameter: {e!s}" status_code=400, detail=f"Invalid state parameter: {e!s}"
@ -162,7 +221,12 @@ async def airtable_callback(
user_id = UUID(data["user_id"]) user_id = UUID(data["user_id"])
space_id = data["space_id"] space_id = data["space_id"]
code_verifier = data["code_verifier"] code_verifier = data.get("code_verifier")
if not code_verifier:
raise HTTPException(
status_code=400, detail="Missing code_verifier in state parameter"
)
auth_header = make_basic_auth_header( auth_header = make_basic_auth_header(
config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET
) )
@ -201,22 +265,38 @@ async def airtable_callback(
token_json = token_response.json() 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 Airtable"
)
# Calculate expiration time (UTC, tz-aware) # Calculate expiration time (UTC, tz-aware)
expires_at = None expires_at = None
if token_json.get("expires_in"): if token_json.get("expires_in"):
now_utc = datetime.now(UTC) now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"])) expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
# Create credentials object # Create credentials object with encrypted tokens
credentials = AirtableAuthCredentialsBase( credentials = AirtableAuthCredentialsBase(
access_token=token_json["access_token"], access_token=token_encryption.encrypt_token(access_token),
refresh_token=token_json.get("refresh_token"), refresh_token=token_encryption.encrypt_token(refresh_token)
if refresh_token
else None,
token_type=token_json.get("token_type", "Bearer"), token_type=token_json.get("token_type", "Bearer"),
expires_in=token_json.get("expires_in"), expires_in=token_json.get("expires_in"),
expires_at=expires_at, expires_at=expires_at,
scope=token_json.get("scope"), scope=token_json.get("scope"),
) )
# Mark that tokens are encrypted for backward compatibility
credentials_dict = credentials.to_dict()
credentials_dict["_token_encrypted"] = True
# Check if connector already exists for this search space and user # Check if connector already exists for this search space and user
existing_connector_result = await session.execute( existing_connector_result = await session.execute(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(
@ -230,7 +310,7 @@ async def airtable_callback(
if existing_connector: if existing_connector:
# Update existing connector # Update existing connector
existing_connector.config = credentials.to_dict() existing_connector.config = credentials_dict
existing_connector.name = "Airtable Connector" existing_connector.name = "Airtable Connector"
existing_connector.is_indexable = True existing_connector.is_indexable = True
logger.info( logger.info(
@ -242,7 +322,7 @@ async def airtable_callback(
name="Airtable Connector", name="Airtable Connector",
connector_type=SearchSourceConnectorType.AIRTABLE_CONNECTOR, connector_type=SearchSourceConnectorType.AIRTABLE_CONNECTOR,
is_indexable=True, is_indexable=True,
config=credentials.to_dict(), config=credentials_dict,
search_space_id=space_id, search_space_id=space_id,
user_id=user_id, user_id=user_id,
) )
@ -306,6 +386,21 @@ async def refresh_airtable_token(
logger.info(f"Refreshing Airtable token for connector {connector.id}") logger.info(f"Refreshing Airtable token for connector {connector.id}")
credentials = AirtableAuthCredentialsBase.from_dict(connector.config) credentials = AirtableAuthCredentialsBase.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
auth_header = make_basic_auth_header( auth_header = make_basic_auth_header(
config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET
) )
@ -313,7 +408,7 @@ async def refresh_airtable_token(
# Prepare token refresh data # Prepare token refresh data
refresh_data = { refresh_data = {
"grant_type": "refresh_token", "grant_type": "refresh_token",
"refresh_token": credentials.refresh_token, "refresh_token": refresh_token,
"client_id": config.AIRTABLE_CLIENT_ID, "client_id": config.AIRTABLE_CLIENT_ID,
"client_secret": config.AIRTABLE_CLIENT_SECRET, "client_secret": config.AIRTABLE_CLIENT_SECRET,
} }
@ -342,14 +437,29 @@ async def refresh_airtable_token(
now_utc = datetime.now(UTC) now_utc = datetime.now(UTC)
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"])) expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
# Update credentials object # Encrypt new tokens before storing
credentials.access_token = token_json["access_token"] 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 Airtable 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 = token_json.get("expires_in") credentials.expires_in = token_json.get("expires_in")
credentials.expires_at = expires_at credentials.expires_at = expires_at
credentials.scope = token_json.get("scope") credentials.scope = token_json.get("scope")
# Update connector config # Update connector config with encrypted tokens
connector.config = credentials.to_dict() credentials_dict = credentials.to_dict()
credentials_dict["_token_encrypted"] = True
connector.config = credentials_dict
await session.commit() await session.commit()
await session.refresh(connector) await session.refresh(connector)

View file

@ -0,0 +1,509 @@
"""
Discord Connector OAuth Routes.
Handles OAuth 2.0 authentication flow for Discord 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.discord_auth_credentials import DiscordAuthCredentialsBase
from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__)
router = APIRouter()
# Discord OAuth endpoints
AUTHORIZATION_URL = "https://discord.com/api/oauth2/authorize"
TOKEN_URL = "https://discord.com/api/oauth2/token"
# OAuth scopes for Discord (Bot Token)
SCOPES = [
"bot", # Basic bot scope
"guilds", # Access to guild information
"guilds.members.read", # Read member information
]
# 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/discord/connector/add")
async def connect_discord(space_id: int, user: User = Depends(current_active_user)):
"""
Initiate Discord 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.DISCORD_CLIENT_ID:
raise HTTPException(status_code=500, detail="Discord OAuth not configured.")
if not config.DISCORD_BOT_TOKEN:
raise HTTPException(
status_code=500,
detail="Discord bot token not configured. Please set DISCORD_BOT_TOKEN in backend configuration.",
)
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 = {
"client_id": config.DISCORD_CLIENT_ID,
"scope": " ".join(SCOPES), # Discord uses space-separated scopes
"redirect_uri": config.DISCORD_REDIRECT_URI,
"response_type": "code",
"state": state_encoded,
}
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
logger.info(f"Generated Discord OAuth URL for user {user.id}, space {space_id}")
return {"auth_url": auth_url}
except Exception as e:
logger.error(f"Failed to initiate Discord OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate Discord OAuth: {e!s}"
) from e
@router.get("/auth/discord/connector/callback")
async def discord_callback(
request: Request,
code: str | None = None,
error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session),
):
"""
Handle Discord OAuth callback.
Args:
request: FastAPI request object
code: Authorization code from Discord (if user granted access)
error: Error code from Discord (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"Discord 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=discord_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=discord_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.DISCORD_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="DISCORD_REDIRECT_URI not configured"
)
# Exchange authorization code for access token
token_data = {
"client_id": config.DISCORD_CLIENT_ID,
"client_secret": config.DISCORD_CLIENT_SECRET,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": config.DISCORD_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()
# Log OAuth response for debugging (without sensitive data)
logger.info(f"Discord OAuth response received. Keys: {list(token_json.keys())}")
# Discord OAuth with 'bot' scope returns access_token (user token), not bot token
# The bot token must come from backend config (DISCORD_BOT_TOKEN)
# OAuth is used to authorize bot installation to servers, not to get bot token
if not config.DISCORD_BOT_TOKEN:
raise HTTPException(
status_code=500,
detail="Discord bot token not configured. Please set DISCORD_BOT_TOKEN in backend configuration.",
)
# Use the bot token from backend config (not the OAuth access_token)
bot_token = config.DISCORD_BOT_TOKEN
# Extract OAuth access_token and refresh_token (for reference, not used for bot operations)
oauth_access_token = token_json.get("access_token")
refresh_token = token_json.get("refresh_token")
# Encrypt sensitive tokens before storing
token_encryption = get_token_encryption()
# 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"]))
# Extract guild info from OAuth response if available
guild_id = None
guild_name = None
if token_json.get("guild"):
guild_id = token_json["guild"].get("id")
guild_name = token_json["guild"].get("name")
# Store the bot token from config and OAuth metadata
connector_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
"refresh_token": token_encryption.encrypt_token(refresh_token)
if refresh_token
else None,
"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"),
"guild_id": guild_id,
"guild_name": guild_name,
# 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.DISCORD_CONNECTOR,
)
)
existing_connector = existing_connector_result.scalars().first()
if existing_connector:
# Update existing connector
existing_connector.config = connector_config
existing_connector.name = "Discord Connector"
existing_connector.is_indexable = True
logger.info(
f"Updated existing Discord connector for user {user_id} in space {space_id}"
)
else:
# Create new connector
new_connector = SearchSourceConnector(
name="Discord Connector",
connector_type=SearchSourceConnectorType.DISCORD_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
user_id=user_id,
)
session.add(new_connector)
logger.info(
f"Created new Discord connector for user {user_id} in space {space_id}"
)
try:
await session.commit()
logger.info(f"Successfully saved Discord 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=discord-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 Discord OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to complete Discord OAuth: {e!s}"
) from e
async def refresh_discord_token(
session: AsyncSession, connector: SearchSourceConnector
) -> 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.
Args:
session: Database session
connector: Discord connector to refresh
Returns:
Updated connector object
"""
try:
logger.info(f"Refreshing Discord OAuth tokens for connector {connector.id}")
# Bot token always comes from config, not from OAuth
if not config.DISCORD_BOT_TOKEN:
raise HTTPException(
status_code=500,
detail="Discord bot token not configured. Please set DISCORD_BOT_TOKEN in backend configuration.",
)
credentials = DiscordAuthCredentialsBase.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 no refresh token, bot token from config is still valid (bot tokens don't expire)
# Just update the bot token from config in case it was changed
if not refresh_token:
logger.info(
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_dict = credentials.to_dict()
credentials_dict["_token_encrypted"] = True
connector.config = credentials_dict
await session.commit()
await session.refresh(connector)
return connector
# Discord uses oauth2/token for token refresh with grant_type=refresh_token
refresh_data = {
"client_id": config.DISCORD_CLIENT_ID,
"client_secret": config.DISCORD_CLIENT_SECRET,
"grant_type": "refresh_token",
"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
# If refresh fails, bot token from config is still valid
logger.warning(
f"OAuth token refresh failed for connector {connector.id}: {error_detail}. "
"Using bot token from config."
)
# Update bot token from config
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
connector.config = credentials_dict
await session.commit()
await session.refresh(connector)
return connector
token_json = token_response.json()
# Extract OAuth access token from refresh response (for reference)
oauth_access_token = token_json.get("access_token")
# Get new refresh token if provided (Discord may rotate refresh tokens)
new_refresh_token = token_json.get("refresh_token")
# 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))
# 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
connector.config["oauth_access_token"] = token_encryption.encrypt_token(
oauth_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 guild info if present
if not credentials.guild_id:
credentials.guild_id = connector.config.get("guild_id")
if not credentials.guild_name:
credentials.guild_name = connector.config.get("guild_name")
if not credentials.bot_user_id:
credentials.bot_user_id = connector.config.get("bot_user_id")
# 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 Discord OAuth tokens for connector {connector.id}")
return connector
except HTTPException:
raise
except Exception as e:
logger.error(
f"Failed to refresh Discord tokens for connector {connector.id}: {e!s}",
exc_info=True,
)
raise HTTPException(
status_code=500, detail=f"Failed to refresh Discord tokens: {e!s}"
) from e

View file

@ -2,7 +2,6 @@ import os
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
import base64
import json import json
import logging import logging
from uuid import UUID from uuid import UUID
@ -23,6 +22,7 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.users import current_active_user from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -31,6 +31,30 @@ router = APIRouter()
SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
REDIRECT_URI = config.GOOGLE_CALENDAR_REDIRECT_URI REDIRECT_URI = config.GOOGLE_CALENDAR_REDIRECT_URI
# 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
def get_google_flow(): def get_google_flow():
try: try:
@ -59,16 +83,16 @@ async def connect_calendar(space_id: int, user: User = Depends(current_active_us
if not space_id: if not space_id:
raise HTTPException(status_code=400, detail="space_id is required") raise HTTPException(status_code=400, detail="space_id is required")
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
flow = get_google_flow() flow = get_google_flow()
# Encode space_id and user_id in state # Generate secure state parameter with HMAC signature
state_payload = json.dumps( state_manager = get_state_manager()
{ state_encoded = state_manager.generate_secure_state(space_id, user.id)
"space_id": space_id,
"user_id": str(user.id),
}
)
state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode()
auth_url, _ = flow.authorization_url( auth_url, _ = flow.authorization_url(
access_type="offline", access_type="offline",
@ -86,24 +110,86 @@ async def connect_calendar(space_id: int, user: User = Depends(current_active_us
@router.get("/auth/google/calendar/connector/callback") @router.get("/auth/google/calendar/connector/callback")
async def calendar_callback( async def calendar_callback(
request: Request, request: Request,
code: str, code: str | None = None,
state: str, error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
try: try:
# Decode and parse the state # Handle OAuth errors (e.g., user denied access)
decoded_state = base64.urlsafe_b64decode(state.encode()).decode() if error:
data = json.loads(decoded_state) logger.warning(f"Google Calendar 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=google_calendar_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=google_calendar_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"]) user_id = UUID(data["user_id"])
space_id = data["space_id"] space_id = data["space_id"]
# Validate redirect URI (security: ensure it matches configured value)
if not config.GOOGLE_CALENDAR_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="GOOGLE_CALENDAR_REDIRECT_URI not configured"
)
flow = get_google_flow() flow = get_google_flow()
flow.fetch_token(code=code) flow.fetch_token(code=code)
creds = flow.credentials creds = flow.credentials
creds_dict = json.loads(creds.to_json()) creds_dict = json.loads(creds.to_json())
# Encrypt sensitive credentials before storing
token_encryption = get_token_encryption()
# Encrypt sensitive fields: token, refresh_token, client_secret
if creds_dict.get("token"):
creds_dict["token"] = token_encryption.encrypt_token(creds_dict["token"])
if creds_dict.get("refresh_token"):
creds_dict["refresh_token"] = token_encryption.encrypt_token(
creds_dict["refresh_token"]
)
if creds_dict.get("client_secret"):
creds_dict["client_secret"] = token_encryption.encrypt_token(
creds_dict["client_secret"]
)
# Mark that credentials are encrypted for backward compatibility
creds_dict["_token_encrypted"] = True
try: try:
# Check if a connector with the same type already exists for this search space and user # Check if a connector with the same type already exists for this search space and user
result = await session.execute( result = await session.execute(

View file

@ -10,7 +10,6 @@ Endpoints:
- GET /connectors/{connector_id}/google-drive/folders - List user's folders (for index-time selection) - GET /connectors/{connector_id}/google-drive/folders - List user's folders (for index-time selection)
""" """
import base64
import json import json
import logging import logging
import os import os
@ -37,6 +36,7 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.users import current_active_user from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
# Relax token scope validation for Google OAuth # Relax token scope validation for Google OAuth
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
@ -44,6 +44,31 @@ os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# 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
# Google Drive OAuth scopes # Google Drive OAuth scopes
SCOPES = [ SCOPES = [
"https://www.googleapis.com/auth/drive.readonly", # Read-only access to Drive "https://www.googleapis.com/auth/drive.readonly", # Read-only access to Drive
@ -90,16 +115,16 @@ async def connect_drive(space_id: int, user: User = Depends(current_active_user)
if not space_id: if not space_id:
raise HTTPException(status_code=400, detail="space_id is required") raise HTTPException(status_code=400, detail="space_id is required")
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
flow = get_google_flow() flow = get_google_flow()
# Encode space_id and user_id in state parameter # Generate secure state parameter with HMAC signature
state_payload = json.dumps( state_manager = get_state_manager()
{ state_encoded = state_manager.generate_secure_state(space_id, user.id)
"space_id": space_id,
"user_id": str(user.id),
}
)
state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode()
# Generate authorization URL # Generate authorization URL
auth_url, _ = flow.authorization_url( auth_url, _ = flow.authorization_url(
@ -124,8 +149,9 @@ async def connect_drive(space_id: int, user: User = Depends(current_active_user)
@router.get("/auth/google/drive/connector/callback") @router.get("/auth/google/drive/connector/callback")
async def drive_callback( async def drive_callback(
request: Request, request: Request,
code: str, code: str | None = None,
state: str, error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
""" """
@ -133,15 +159,53 @@ async def drive_callback(
Query params: Query params:
code: Authorization code from Google code: Authorization code from Google
error: OAuth error (if user denied access)
state: Encoded state with space_id and user_id state: Encoded state with space_id and user_id
Returns: Returns:
Redirect to frontend success page Redirect to frontend success page
""" """
try: try:
# Decode and parse state # Handle OAuth errors (e.g., user denied access)
decoded_state = base64.urlsafe_b64decode(state.encode()).decode() if error:
data = json.loads(decoded_state) logger.warning(f"Google Drive 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=google_drive_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=google_drive_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"]) user_id = UUID(data["user_id"])
space_id = data["space_id"] space_id = data["space_id"]
@ -150,6 +214,12 @@ async def drive_callback(
f"Processing Google Drive callback for user {user_id}, space {space_id}" f"Processing Google Drive callback for user {user_id}, space {space_id}"
) )
# Validate redirect URI (security: ensure it matches configured value)
if not config.GOOGLE_DRIVE_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="GOOGLE_DRIVE_REDIRECT_URI not configured"
)
# Exchange authorization code for tokens # Exchange authorization code for tokens
flow = get_google_flow() flow = get_google_flow()
flow.fetch_token(code=code) flow.fetch_token(code=code)
@ -157,6 +227,24 @@ async def drive_callback(
creds = flow.credentials creds = flow.credentials
creds_dict = json.loads(creds.to_json()) creds_dict = json.loads(creds.to_json())
# Encrypt sensitive credentials before storing
token_encryption = get_token_encryption()
# Encrypt sensitive fields: token, refresh_token, client_secret
if creds_dict.get("token"):
creds_dict["token"] = token_encryption.encrypt_token(creds_dict["token"])
if creds_dict.get("refresh_token"):
creds_dict["refresh_token"] = token_encryption.encrypt_token(
creds_dict["refresh_token"]
)
if creds_dict.get("client_secret"):
creds_dict["client_secret"] = token_encryption.encrypt_token(
creds_dict["client_secret"]
)
# Mark that credentials are encrypted for backward compatibility
creds_dict["_token_encrypted"] = True
# Check if connector already exists for this space/user # Check if connector already exists for this space/user
result = await session.execute( result = await session.execute(
select(SearchSourceConnector).filter( select(SearchSourceConnector).filter(

View file

@ -2,7 +2,6 @@ import os
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
import base64
import json import json
import logging import logging
from uuid import UUID from uuid import UUID
@ -23,14 +22,40 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.users import current_active_user from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# 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
def get_google_flow(): def get_google_flow():
"""Create and return a Google OAuth flow for Gmail API.""" """Create and return a Google OAuth flow for Gmail API."""
try:
flow = Flow.from_client_config( flow = Flow.from_client_config(
{ {
"web": { "web": {
@ -50,24 +75,37 @@ def get_google_flow():
) )
flow.redirect_uri = config.GOOGLE_GMAIL_REDIRECT_URI flow.redirect_uri = config.GOOGLE_GMAIL_REDIRECT_URI
return flow return flow
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to create Google flow: {e!s}"
) from e
@router.get("/auth/google/gmail/connector/add") @router.get("/auth/google/gmail/connector/add")
async def connect_gmail(space_id: int, user: User = Depends(current_active_user)): async def connect_gmail(space_id: int, user: User = Depends(current_active_user)):
"""
Initiate Google Gmail OAuth flow.
Query params:
space_id: Search space ID to add connector to
Returns:
JSON with auth_url to redirect user to Google authorization
"""
try: try:
if not space_id: if not space_id:
raise HTTPException(status_code=400, detail="space_id is required") raise HTTPException(status_code=400, detail="space_id is required")
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
flow = get_google_flow() flow = get_google_flow()
# Encode space_id and user_id in state # Generate secure state parameter with HMAC signature
state_payload = json.dumps( state_manager = get_state_manager()
{ state_encoded = state_manager.generate_secure_state(space_id, user.id)
"space_id": space_id,
"user_id": str(user.id),
}
)
state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode()
auth_url, _ = flow.authorization_url( auth_url, _ = flow.authorization_url(
access_type="offline", access_type="offline",
@ -75,8 +113,13 @@ async def connect_gmail(space_id: int, user: User = Depends(current_active_user)
include_granted_scopes="true", include_granted_scopes="true",
state=state_encoded, state=state_encoded,
) )
logger.info(
f"Initiating Google Gmail OAuth for user {user.id}, space {space_id}"
)
return {"auth_url": auth_url} return {"auth_url": auth_url}
except Exception as e: except Exception as e:
logger.error(f"Failed to initiate Google Gmail OAuth: {e!s}", exc_info=True)
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to initiate Google OAuth: {e!s}" status_code=500, detail=f"Failed to initiate Google OAuth: {e!s}"
) from e ) from e
@ -85,24 +128,99 @@ async def connect_gmail(space_id: int, user: User = Depends(current_active_user)
@router.get("/auth/google/gmail/connector/callback") @router.get("/auth/google/gmail/connector/callback")
async def gmail_callback( async def gmail_callback(
request: Request, request: Request,
code: str, code: str | None = None,
state: str, error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
): ):
"""
Handle Google Gmail OAuth callback.
Args:
request: FastAPI request object
code: Authorization code from Google (if user granted access)
error: Error code from Google (if user denied access or error occurred)
state: State parameter containing user/space info
session: Database session
Returns:
Redirect response to frontend
"""
try: try:
# Decode and parse the state # Handle OAuth errors (e.g., user denied access)
decoded_state = base64.urlsafe_b64decode(state.encode()).decode() if error:
data = json.loads(decoded_state) logger.warning(f"Google Gmail 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=google_gmail_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=google_gmail_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"]) user_id = UUID(data["user_id"])
space_id = data["space_id"] space_id = data["space_id"]
# Validate redirect URI (security: ensure it matches configured value)
if not config.GOOGLE_GMAIL_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="GOOGLE_GMAIL_REDIRECT_URI not configured"
)
flow = get_google_flow() flow = get_google_flow()
flow.fetch_token(code=code) flow.fetch_token(code=code)
creds = flow.credentials creds = flow.credentials
creds_dict = json.loads(creds.to_json()) creds_dict = json.loads(creds.to_json())
# Encrypt sensitive credentials before storing
token_encryption = get_token_encryption()
# Encrypt sensitive fields: token, refresh_token, client_secret
if creds_dict.get("token"):
creds_dict["token"] = token_encryption.encrypt_token(creds_dict["token"])
if creds_dict.get("refresh_token"):
creds_dict["refresh_token"] = token_encryption.encrypt_token(
creds_dict["refresh_token"]
)
if creds_dict.get("client_secret"):
creds_dict["client_secret"] = token_encryption.encrypt_token(
creds_dict["client_secret"]
)
# Mark that credentials are encrypted for backward compatibility
creds_dict["_token_encrypted"] = True
try: try:
# Check if a connector with the same type already exists for this search space and user # Check if a connector with the same type already exists for this search space and user
result = await session.execute( result = await session.execute(
@ -160,3 +278,6 @@ async def gmail_callback(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in Gmail callback: {e!s}", exc_info=True) logger.error(f"Unexpected error in Gmail callback: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to complete Google Gmail OAuth: {e!s}"
) from e

View file

@ -0,0 +1,448 @@
"""
Linear Connector OAuth Routes.
Handles OAuth 2.0 authentication flow for Linear 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.linear_auth_credentials import LinearAuthCredentialsBase
from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
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"]
# 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
def make_basic_auth_header(client_id: str, client_secret: str) -> str:
"""Create Basic Auth header for Linear OAuth."""
import base64
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.")
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 = {
"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 | None = None,
error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session),
):
"""
Handle Linear OAuth callback.
Args:
request: FastAPI request object
code: Authorization code from Linear (if user granted access)
error: Error code from Linear (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"Linear 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=linear_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=linear_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.LINEAR_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="LINEAR_REDIRECT_URI not configured"
)
# 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, # Use stored value, not from request
}
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()
# 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 Linear"
)
# 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 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": token_json.get("expires_in"),
"expires_at": expires_at.isoformat() if expires_at else None,
"scope": token_json.get("scope"),
# 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.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
async def refresh_linear_token(
session: AsyncSession, connector: SearchSourceConnector
) -> SearchSourceConnector:
"""
Refresh the Linear access token for a connector.
Args:
session: Database session
connector: Linear connector to refresh
Returns:
Updated connector object
"""
try:
logger.info(f"Refreshing Linear token for connector {connector.id}")
credentials = LinearAuthCredentialsBase.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.",
)
auth_header = make_basic_auth_header(
config.LINEAR_CLIENT_ID, config.LINEAR_CLIENT_SECRET
)
# Prepare token refresh data
refresh_data = {
"grant_type": "refresh_token",
"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",
"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 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 Linear 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")
# 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 Linear token for connector {connector.id}")
return connector
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to refresh Linear token: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to refresh Linear token: {e!s}"
) from e

View file

@ -0,0 +1,459 @@
"""
Notion Connector OAuth Routes.
Handles OAuth 2.0 authentication flow for Notion 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.notion_auth_credentials import NotionAuthCredentialsBase
from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__)
router = APIRouter()
# Notion OAuth endpoints
AUTHORIZATION_URL = "https://api.notion.com/v1/oauth/authorize"
TOKEN_URL = "https://api.notion.com/v1/oauth/token"
# 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
def make_basic_auth_header(client_id: str, client_secret: str) -> str:
"""Create Basic Auth header for Notion OAuth."""
import base64
credentials = f"{client_id}:{client_secret}".encode()
b64 = base64.b64encode(credentials).decode("ascii")
return f"Basic {b64}"
@router.get("/auth/notion/connector/add")
async def connect_notion(space_id: int, user: User = Depends(current_active_user)):
"""
Initiate Notion 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.NOTION_CLIENT_ID:
raise HTTPException(status_code=500, detail="Notion 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 = {
"client_id": config.NOTION_CLIENT_ID,
"response_type": "code",
"owner": "user", # Allows both admins and members to authorize
"redirect_uri": config.NOTION_REDIRECT_URI,
"state": state_encoded,
}
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
logger.info(f"Generated Notion OAuth URL for user {user.id}, space {space_id}")
return {"auth_url": auth_url}
except Exception as e:
logger.error(f"Failed to initiate Notion OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate Notion OAuth: {e!s}"
) from e
@router.get("/auth/notion/connector/callback")
async def notion_callback(
request: Request,
code: str | None = None,
error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session),
):
"""
Handle Notion OAuth callback.
Args:
request: FastAPI request object
code: Authorization code from Notion (if user granted access)
error: Error code from Notion (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"Notion 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=notion_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=notion_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)
# Note: Notion doesn't send redirect_uri in callback, but we validate
# that we're using the configured one in token exchange
if not config.NOTION_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="NOTION_REDIRECT_URI not configured"
)
# Exchange authorization code for access token
auth_header = make_basic_auth_header(
config.NOTION_CLIENT_ID, config.NOTION_CLIENT_SECRET
)
token_data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": config.NOTION_REDIRECT_URI, # Use stored value, not from request
}
async with httpx.AsyncClient() as client:
token_response = await client.post(
TOKEN_URL,
json=token_data,
headers={
"Content-Type": "application/json",
"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()
# 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 Notion"
)
# 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))
# Notion returns access_token, refresh_token (if available), and workspace information
# Store the encrypted tokens and workspace info 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,
"expires_in": expires_in,
"expires_at": expires_at.isoformat() if expires_at else None,
"workspace_id": token_json.get("workspace_id"),
"workspace_name": token_json.get("workspace_name"),
"workspace_icon": token_json.get("workspace_icon"),
"bot_id": token_json.get("bot_id"),
# Mark that token is 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.NOTION_CONNECTOR,
)
)
existing_connector = existing_connector_result.scalars().first()
if existing_connector:
# Update existing connector
existing_connector.config = connector_config
existing_connector.name = "Notion Connector"
existing_connector.is_indexable = True
logger.info(
f"Updated existing Notion connector for user {user_id} in space {space_id}"
)
else:
# Create new connector
new_connector = SearchSourceConnector(
name="Notion Connector",
connector_type=SearchSourceConnectorType.NOTION_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
user_id=user_id,
)
session.add(new_connector)
logger.info(
f"Created new Notion connector for user {user_id} in space {space_id}"
)
try:
await session.commit()
logger.info(f"Successfully saved Notion 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=notion-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 Notion OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to complete Notion OAuth: {e!s}"
) from e
async def refresh_notion_token(
session: AsyncSession, connector: SearchSourceConnector
) -> SearchSourceConnector:
"""
Refresh the Notion access token for a connector.
Args:
session: Database session
connector: Notion connector to refresh
Returns:
Updated connector object
"""
try:
logger.info(f"Refreshing Notion token for connector {connector.id}")
credentials = NotionAuthCredentialsBase.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.",
)
auth_header = make_basic_auth_header(
config.NOTION_CLIENT_ID, config.NOTION_CLIENT_SECRET
)
# Prepare token refresh data
refresh_data = {
"grant_type": "refresh_token",
"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",
"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 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 Notion 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
# Preserve workspace info
if not credentials.workspace_id:
credentials.workspace_id = connector.config.get("workspace_id")
if not credentials.workspace_name:
credentials.workspace_name = connector.config.get("workspace_name")
if not credentials.workspace_icon:
credentials.workspace_icon = connector.config.get("workspace_icon")
if not credentials.bot_id:
credentials.bot_id = connector.config.get("bot_id")
# 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 Notion token for connector {connector.id}")
return connector
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to refresh Notion token: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to refresh Notion token: {e!s}"
) from e

View file

@ -0,0 +1,478 @@
"""
Slack Connector OAuth Routes.
Handles OAuth 2.0 authentication flow for Slack 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.slack_auth_credentials import SlackAuthCredentialsBase
from app.users import current_active_user
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
logger = logging.getLogger(__name__)
router = APIRouter()
# Slack OAuth endpoints
AUTHORIZATION_URL = "https://slack.com/oauth/v2/authorize"
TOKEN_URL = "https://slack.com/api/oauth.v2.access"
# OAuth scopes for Slack (Bot Token)
SCOPES = [
"channels:history", # Read messages in public channels
"channels:read", # View basic information about public channels
"groups:history", # Read messages in private channels
"groups:read", # View basic information about private channels
"im:history", # Read messages in direct messages
"mpim:history", # Read messages in group direct messages
"users:read", # Read user information
]
# 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/slack/connector/add")
async def connect_slack(space_id: int, user: User = Depends(current_active_user)):
"""
Initiate Slack 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.SLACK_CLIENT_ID:
raise HTTPException(status_code=500, detail="Slack 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 = {
"client_id": config.SLACK_CLIENT_ID,
"scope": ",".join(SCOPES),
"redirect_uri": config.SLACK_REDIRECT_URI,
"state": state_encoded,
}
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
logger.info(f"Generated Slack OAuth URL for user {user.id}, space {space_id}")
return {"auth_url": auth_url}
except Exception as e:
logger.error(f"Failed to initiate Slack OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate Slack OAuth: {e!s}"
) from e
@router.get("/auth/slack/connector/callback")
async def slack_callback(
request: Request,
code: str | None = None,
error: str | None = None,
state: str | None = None,
session: AsyncSession = Depends(get_async_session),
):
"""
Handle Slack OAuth callback.
Args:
request: FastAPI request object
code: Authorization code from Slack (if user granted access)
error: Error code from Slack (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"Slack 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=slack_oauth_denied"
)
else:
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=slack_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.SLACK_REDIRECT_URI:
raise HTTPException(
status_code=500, detail="SLACK_REDIRECT_URI not configured"
)
# Exchange authorization code for access token
token_data = {
"client_id": config.SLACK_CLIENT_ID,
"client_secret": config.SLACK_CLIENT_SECRET,
"code": code,
"redirect_uri": config.SLACK_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", error_detail)
except Exception:
pass
raise HTTPException(
status_code=400, detail=f"Token exchange failed: {error_detail}"
)
token_json = token_response.json()
# Slack OAuth v2 returns success status in the JSON
if not token_json.get("ok", False):
error_msg = token_json.get("error", "Unknown error")
raise HTTPException(
status_code=400, detail=f"Slack OAuth error: {error_msg}"
)
# Extract bot token from Slack response
# Slack OAuth v2 returns: { "ok": true, "access_token": "...", "bot": { "bot_user_id": "...", "bot_access_token": "xoxb-..." }, "refresh_token": "...", ... }
bot_token = None
if token_json.get("bot") and token_json["bot"].get("bot_access_token"):
bot_token = token_json["bot"]["bot_access_token"]
elif token_json.get("access_token"):
# Fallback to access_token if bot token not available
bot_token = token_json["access_token"]
else:
raise HTTPException(
status_code=400, detail="No bot token received from Slack"
)
# Extract refresh token if available (for token rotation)
refresh_token = token_json.get("refresh_token")
# Encrypt sensitive tokens before storing
token_encryption = get_token_encryption()
# Calculate expiration time (UTC, tz-aware)
# Slack tokens don't expire by default, but we'll store expiration info if provided
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 encrypted bot token and refresh token in connector config
connector_config = {
"bot_token": token_encryption.encrypt_token(bot_token),
"refresh_token": token_encryption.encrypt_token(refresh_token)
if refresh_token
else None,
"bot_user_id": token_json.get("bot", {}).get("bot_user_id"),
"team_id": token_json.get("team", {}).get("id"),
"team_name": token_json.get("team", {}).get("name"),
"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"),
# 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.SLACK_CONNECTOR,
)
)
existing_connector = existing_connector_result.scalars().first()
if existing_connector:
# Update existing connector
existing_connector.config = connector_config
existing_connector.name = "Slack Connector"
existing_connector.is_indexable = True
logger.info(
f"Updated existing Slack connector for user {user_id} in space {space_id}"
)
else:
# Create new connector
new_connector = SearchSourceConnector(
name="Slack Connector",
connector_type=SearchSourceConnectorType.SLACK_CONNECTOR,
is_indexable=True,
config=connector_config,
search_space_id=space_id,
user_id=user_id,
)
session.add(new_connector)
logger.info(
f"Created new Slack connector for user {user_id} in space {space_id}"
)
try:
await session.commit()
logger.info(f"Successfully saved Slack 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=slack-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 Slack OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to complete Slack OAuth: {e!s}"
) from e
async def refresh_slack_token(
session: AsyncSession, connector: SearchSourceConnector
) -> SearchSourceConnector:
"""
Refresh the Slack bot token for a connector.
Args:
session: Database session
connector: Slack connector to refresh
Returns:
Updated connector object
"""
try:
logger.info(f"Refreshing Slack token for connector {connector.id}")
credentials = SlackAuthCredentialsBase.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.",
)
# Slack uses oauth.v2.access for token refresh with grant_type=refresh_token
refresh_data = {
"client_id": config.SLACK_CLIENT_ID,
"client_secret": config.SLACK_CLIENT_SECRET,
"grant_type": "refresh_token",
"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", error_detail)
except Exception:
pass
raise HTTPException(
status_code=400, detail=f"Token refresh failed: {error_detail}"
)
token_json = token_response.json()
# Slack OAuth v2 returns success status in the JSON
if not token_json.get("ok", False):
error_msg = token_json.get("error", "Unknown error")
raise HTTPException(
status_code=400, detail=f"Slack OAuth refresh error: {error_msg}"
)
# Extract bot token from refresh response
bot_token = None
if token_json.get("bot") and token_json["bot"].get("bot_access_token"):
bot_token = token_json["bot"]["bot_access_token"]
elif token_json.get("access_token"):
bot_token = token_json["access_token"]
else:
raise HTTPException(
status_code=400, detail="No bot token received from Slack refresh"
)
# Get new refresh token if provided (Slack may rotate refresh tokens)
new_refresh_token = token_json.get("refresh_token")
# 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))
# Update credentials object with encrypted tokens
credentials.bot_token = token_encryption.encrypt_token(bot_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 team info
if not credentials.team_id:
credentials.team_id = connector.config.get("team_id")
if not credentials.team_name:
credentials.team_name = connector.config.get("team_name")
if not credentials.bot_user_id:
credentials.bot_user_id = connector.config.get("bot_user_id")
# 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 Slack token for connector {connector.id}")
return connector
except HTTPException:
raise
except Exception as e:
logger.error(
f"Failed to refresh Slack token for connector {connector.id}: {e!s}",
exc_info=True,
)
raise HTTPException(
status_code=500, detail=f"Failed to refresh Slack token: {e!s}"
) from e

View file

@ -0,0 +1,76 @@
from datetime import UTC, datetime
from pydantic import BaseModel, field_validator
class DiscordAuthCredentialsBase(BaseModel):
bot_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
bot_user_id: str | None = None
guild_id: str | None = None
guild_name: str | None = None
@property
def is_expired(self) -> bool:
"""Check if the credentials have expired."""
if self.expires_at is None:
return False # Long-lived token, treat as not expired
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 {
"bot_token": self.bot_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,
"bot_user_id": self.bot_user_id,
"guild_id": self.guild_id,
"guild_name": self.guild_name,
}
@classmethod
def from_dict(cls, data: dict) -> "DiscordAuthCredentialsBase":
"""Create credentials from dictionary."""
expires_at = None
if data.get("expires_at"):
expires_at = datetime.fromisoformat(data["expires_at"])
return cls(
bot_token=data.get("bot_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"),
bot_user_id=data.get("bot_user_id"),
guild_id=data.get("guild_id"),
guild_name=data.get("guild_name"),
)
@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

View file

@ -0,0 +1,66 @@
from datetime import UTC, datetime
from pydantic import BaseModel, field_validator
class LinearAuthCredentialsBase(BaseModel):
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
@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,
}
@classmethod
def from_dict(cls, data: dict) -> "LinearAuthCredentialsBase":
"""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"),
)
@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

View file

@ -0,0 +1,72 @@
from datetime import UTC, datetime
from pydantic import BaseModel, field_validator
class NotionAuthCredentialsBase(BaseModel):
access_token: str
refresh_token: str | None = None
expires_in: int | None = None
expires_at: datetime | None = None
workspace_id: str | None = None
workspace_name: str | None = None
workspace_icon: str | None = None
bot_id: str | None = None
@property
def is_expired(self) -> bool:
"""Check if the credentials have expired."""
if self.expires_at is None:
return False # Long-lived token, treat as not expired
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,
"expires_in": self.expires_in,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"workspace_id": self.workspace_id,
"workspace_name": self.workspace_name,
"workspace_icon": self.workspace_icon,
"bot_id": self.bot_id,
}
@classmethod
def from_dict(cls, data: dict) -> "NotionAuthCredentialsBase":
"""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"),
expires_in=data.get("expires_in"),
expires_at=expires_at,
workspace_id=data.get("workspace_id"),
workspace_name=data.get("workspace_name"),
workspace_icon=data.get("workspace_icon"),
bot_id=data.get("bot_id"),
)
@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

View file

@ -30,7 +30,12 @@ class SearchSourceConnectorBase(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def validate_periodic_indexing(self): def validate_periodic_indexing(self):
"""Validate that periodic indexing configuration is consistent.""" """Validate that periodic indexing configuration is consistent.
Supported frequencies: Any positive integer (in minutes).
Common values: 5, 15, 60 (1 hour), 360 (6 hours), 720 (12 hours), 1440 (daily), etc.
The schedule checker will handle any frequency >= 1 minute.
"""
if self.periodic_indexing_enabled: if self.periodic_indexing_enabled:
if not self.is_indexable: if not self.is_indexable:
raise ValueError( raise ValueError(

View file

@ -0,0 +1,75 @@
from datetime import UTC, datetime
from pydantic import BaseModel, field_validator
class SlackAuthCredentialsBase(BaseModel):
bot_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
bot_user_id: str | None = None
team_id: str | None = None
team_name: str | None = None
@property
def is_expired(self) -> bool:
"""Check if the credentials have expired."""
if self.expires_at is None:
return False # Long-lived token, treat as not expired
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 {
"bot_token": self.bot_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,
"bot_user_id": self.bot_user_id,
"team_id": self.team_id,
"team_name": self.team_name,
}
@classmethod
def from_dict(cls, data: dict) -> "SlackAuthCredentialsBase":
"""Create credentials from dictionary."""
expires_at = None
if data.get("expires_at"):
expires_at = datetime.fromisoformat(data["expires_at"])
return cls(
bot_token=data.get("bot_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"),
bot_user_id=data.get("bot_user_id"),
team_id=data.get("team_id"),
team_name=data.get("team_name"),
)
@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

View file

@ -270,7 +270,8 @@ async def stream_new_chat(
# Track if we just finished a tool (text flows silently after tools) # Track if we just finished a tool (text flows silently after tools)
just_finished_tool: bool = False just_finished_tool: bool = False
# Track write_todos calls to show "Creating plan" vs "Updating plan" # Track write_todos calls to show "Creating plan" vs "Updating plan"
write_todos_call_count: int = 0 # Disabled for now
# write_todos_call_count: int = 0
def next_thinking_step_id() -> str: def next_thinking_step_id() -> str:
nonlocal thinking_step_counter nonlocal thinking_step_counter
@ -479,60 +480,60 @@ async def stream_new_chat(
status="in_progress", status="in_progress",
items=last_active_step_items, items=last_active_step_items,
) )
elif tool_name == "write_todos": # elif tool_name == "write_todos": # Disabled for now
# Track write_todos calls for better messaging # # Track write_todos calls for better messaging
write_todos_call_count += 1 # write_todos_call_count += 1
todos = ( # todos = (
tool_input.get("todos", []) # tool_input.get("todos", [])
if isinstance(tool_input, dict) # if isinstance(tool_input, dict)
else [] # else []
) # )
todo_count = len(todos) if isinstance(todos, list) else 0 # todo_count = len(todos) if isinstance(todos, list) else 0
if write_todos_call_count == 1: # if write_todos_call_count == 1:
# First call - creating the plan # # First call - creating the plan
last_active_step_title = "Creating plan" # last_active_step_title = "Creating plan"
last_active_step_items = [f"Defining {todo_count} tasks..."] # last_active_step_items = [f"Defining {todo_count} tasks..."]
else: # else:
# Subsequent calls - updating the plan # # Subsequent calls - updating the plan
# Try to provide context about what's being updated # # Try to provide context about what's being updated
in_progress_count = ( # in_progress_count = (
sum( # sum(
1 # 1
for t in todos # for t in todos
if isinstance(t, dict) # if isinstance(t, dict)
and t.get("status") == "in_progress" # and t.get("status") == "in_progress"
) # )
if isinstance(todos, list) # if isinstance(todos, list)
else 0 # else 0
) # )
completed_count = ( # completed_count = (
sum( # sum(
1 # 1
for t in todos # for t in todos
if isinstance(t, dict) # if isinstance(t, dict)
and t.get("status") == "completed" # and t.get("status") == "completed"
) # )
if isinstance(todos, list) # if isinstance(todos, list)
else 0 # else 0
) # )
last_active_step_title = "Updating progress" # last_active_step_title = "Updating progress"
last_active_step_items = ( # last_active_step_items = (
[ # [
f"Progress: {completed_count}/{todo_count} completed", # f"Progress: {completed_count}/{todo_count} completed",
f"In progress: {in_progress_count} tasks", # f"In progress: {in_progress_count} tasks",
] # ]
if completed_count > 0 # if completed_count > 0
else [f"Working on {todo_count} tasks"] # else [f"Working on {todo_count} tasks"]
) # )
yield streaming_service.format_thinking_step( # yield streaming_service.format_thinking_step(
step_id=tool_step_id, # step_id=tool_step_id,
title=last_active_step_title, # title=last_active_step_title,
status="in_progress", # status="in_progress",
items=last_active_step_items, # items=last_active_step_items,
) # )
elif tool_name == "generate_podcast": elif tool_name == "generate_podcast":
podcast_title = ( podcast_title = (
tool_input.get("podcast_title", "SurfSense Podcast") tool_input.get("podcast_title", "SurfSense Podcast")
@ -596,10 +597,12 @@ async def stream_new_chat(
raw_output = event.get("data", {}).get("output", "") raw_output = event.get("data", {}).get("output", "")
# Handle deepagents' write_todos Command object specially # Handle deepagents' write_todos Command object specially
if tool_name == "write_todos" and hasattr(raw_output, "update"): # Disabled for now
# deepagents returns a Command object - extract todos directly # if tool_name == "write_todos" and hasattr(raw_output, "update"):
tool_output = extract_todos_from_deepagents(raw_output) # # deepagents returns a Command object - extract todos directly
elif hasattr(raw_output, "content"): # tool_output = extract_todos_from_deepagents(raw_output)
# elif hasattr(raw_output, "content"):
if hasattr(raw_output, "content"):
# It's a ToolMessage object - extract the content # It's a ToolMessage object - extract the content
content = raw_output.content content = raw_output.content
# If content is a string that looks like JSON, try to parse it # If content is a string that looks like JSON, try to parse it
@ -758,63 +761,63 @@ async def stream_new_chat(
status="completed", status="completed",
items=completed_items, items=completed_items,
) )
elif tool_name == "write_todos": # elif tool_name == "write_todos": # Disabled for now
# Build completion items for planning/updating # # Build completion items for planning/updating
if isinstance(tool_output, dict): # if isinstance(tool_output, dict):
todos = tool_output.get("todos", []) # todos = tool_output.get("todos", [])
todo_count = len(todos) if isinstance(todos, list) else 0 # todo_count = len(todos) if isinstance(todos, list) else 0
completed_count = ( # completed_count = (
sum( # sum(
1 # 1
for t in todos # for t in todos
if isinstance(t, dict) # if isinstance(t, dict)
and t.get("status") == "completed" # and t.get("status") == "completed"
) # )
if isinstance(todos, list) # if isinstance(todos, list)
else 0 # else 0
) # )
in_progress_count = ( # in_progress_count = (
sum( # sum(
1 # 1
for t in todos # for t in todos
if isinstance(t, dict) # if isinstance(t, dict)
and t.get("status") == "in_progress" # and t.get("status") == "in_progress"
) # )
if isinstance(todos, list) # if isinstance(todos, list)
else 0 # else 0
) # )
# Use context-aware completion message # # Use context-aware completion message
if last_active_step_title == "Creating plan": # if last_active_step_title == "Creating plan":
completed_items = [f"Created {todo_count} tasks"] # completed_items = [f"Created {todo_count} tasks"]
else: # else:
# Updating progress - show stats # # Updating progress - show stats
completed_items = [ # completed_items = [
f"Progress: {completed_count}/{todo_count} completed", # f"Progress: {completed_count}/{todo_count} completed",
] # ]
if in_progress_count > 0: # if in_progress_count > 0:
# Find the currently in-progress task name # # Find the currently in-progress task name
in_progress_task = next( # in_progress_task = next(
( # (
t.get("content", "")[:40] # t.get("content", "")[:40]
for t in todos # for t in todos
if isinstance(t, dict) # if isinstance(t, dict)
and t.get("status") == "in_progress" # and t.get("status") == "in_progress"
), # ),
None, # None,
) # )
if in_progress_task: # if in_progress_task:
completed_items.append( # completed_items.append(
f"Current: {in_progress_task}..." # f"Current: {in_progress_task}..."
) # )
else: # else:
completed_items = ["Plan updated"] # completed_items = ["Plan updated"]
yield streaming_service.format_thinking_step( # yield streaming_service.format_thinking_step(
step_id=original_step_id, # step_id=original_step_id,
title=last_active_step_title, # title=last_active_step_title,
status="completed", # status="completed",
items=completed_items, # items=completed_items,
) # )
elif tool_name == "ls": elif tool_name == "ls":
# Build completion items showing file names found # Build completion items showing file names found
if isinstance(tool_output, dict): if isinstance(tool_output, dict):
@ -992,27 +995,27 @@ async def stream_new_chat(
yield streaming_service.format_terminal_info( yield streaming_service.format_terminal_info(
"Knowledge base search completed", "success" "Knowledge base search completed", "success"
) )
elif tool_name == "write_todos": # elif tool_name == "write_todos": # Disabled for now
# Stream the full write_todos result so frontend can render the Plan component # # Stream the full write_todos result so frontend can render the Plan component
yield streaming_service.format_tool_output_available( # yield streaming_service.format_tool_output_available(
tool_call_id, # tool_call_id,
tool_output # tool_output
if isinstance(tool_output, dict) # if isinstance(tool_output, dict)
else {"result": tool_output}, # else {"result": tool_output},
) # )
# Send terminal message with plan info # # Send terminal message with plan info
if isinstance(tool_output, dict): # if isinstance(tool_output, dict):
todos = tool_output.get("todos", []) # todos = tool_output.get("todos", [])
todo_count = len(todos) if isinstance(todos, list) else 0 # todo_count = len(todos) if isinstance(todos, list) else 0
yield streaming_service.format_terminal_info( # yield streaming_service.format_terminal_info(
f"Plan created ({todo_count} tasks)", # f"Plan created ({todo_count} tasks)",
"success", # "success",
) # )
else: # else:
yield streaming_service.format_terminal_info( # yield streaming_service.format_terminal_info(
"Plan created", # "Plan created",
"success", # "success",
) # )
else: else:
# Default handling for other tools # Default handling for other tools
yield streaming_service.format_tool_output_available( yield streaming_service.format_tool_output_available(

View file

@ -18,6 +18,7 @@ from app.utils.document_converters import (
generate_document_summary, generate_document_summary,
generate_unique_identifier_hash, generate_unique_identifier_hash,
) )
from app.utils.oauth_security import TokenEncryption
from .base import ( from .base import (
calculate_date_range, calculate_date_range,
@ -85,7 +86,52 @@ async def index_airtable_records(
return 0, f"Connector with ID {connector_id} not found" return 0, f"Connector with ID {connector_id} not found"
# Create credentials from connector config # Create credentials from connector config
config_data = connector.config config_data = (
connector.config.copy()
) # Work with a copy to avoid modifying original
# Decrypt tokens if they are encrypted (only when explicitly marked)
token_encrypted = config_data.get("_token_encrypted", False)
if token_encrypted:
# Tokens are explicitly marked as encrypted, attempt decryption
if not config.SECRET_KEY:
await task_logger.log_task_failure(
log_entry,
f"SECRET_KEY not configured but tokens are marked as encrypted for connector {connector_id}",
"Missing SECRET_KEY for token decryption",
{"error_type": "MissingSecretKey"},
)
return 0, "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 Airtable access token for connector {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 Airtable refresh token for connector {connector_id}"
)
except Exception as e:
await task_logger.log_task_failure(
log_entry,
f"Failed to decrypt Airtable tokens for connector {connector_id}: {e!s}",
"Token decryption failed",
{"error_type": "TokenDecryptionError"},
)
return 0, f"Failed to decrypt Airtable tokens: {e!s}"
# If _token_encrypted is False or not set, treat tokens as plaintext
try: try:
credentials = AirtableAuthCredentialsBase.from_dict(config_data) credentials = AirtableAuthCredentialsBase.from_dict(config_data)
except Exception as e: except Exception as e:

View file

@ -8,6 +8,7 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.discord_connector import DiscordConnector from app.connectors.discord_connector import DiscordConnector
from app.db import Document, DocumentType, SearchSourceConnectorType from app.db import Document, DocumentType, SearchSourceConnectorType
from app.services.llm_service import get_user_long_context_llm from app.services.llm_service import get_user_long_context_llm
@ -69,6 +70,12 @@ async def index_discord_messages(
) )
try: try:
# Normalize date parameters - handle 'undefined' strings from frontend
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
# Get the connector # Get the connector
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, log_entry,
@ -92,27 +99,54 @@ async def index_discord_messages(
f"Connector with ID {connector_id} not found or is not a Discord connector", f"Connector with ID {connector_id} not found or is not a Discord connector",
) )
# Get the Discord token from the connector config
discord_token = connector.config.get("DISCORD_BOT_TOKEN")
if not discord_token:
await task_logger.log_task_failure(
log_entry,
f"Discord token not found in connector config for connector {connector_id}",
"Missing Discord token",
{"error_type": "MissingToken"},
)
return 0, "Discord token not found in connector config"
logger.info(f"Starting Discord indexing for connector {connector_id}") logger.info(f"Starting Discord indexing for connector {connector_id}")
# Initialize Discord client # Initialize Discord client with OAuth credentials support
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, log_entry,
f"Initializing Discord client for connector {connector_id}", f"Initializing Discord client for connector {connector_id}",
{"stage": "client_initialization"}, {"stage": "client_initialization"},
) )
# Check if using OAuth (has bot_token in config) or legacy (has DISCORD_BOT_TOKEN)
has_oauth = connector.config.get("bot_token") is not None
has_legacy = connector.config.get("DISCORD_BOT_TOKEN") is not None
if has_oauth:
# Use OAuth credentials with auto-refresh
discord_client = DiscordConnector(
session=session, connector_id=connector_id
)
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(
f"Decrypted legacy Discord token for connector {connector_id}"
)
except Exception as e:
logger.warning(
f"Failed to decrypt legacy Discord token for connector {connector_id}: {e!s}. "
"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) discord_client = DiscordConnector(token=discord_token)
else:
await task_logger.log_task_failure(
log_entry,
f"Discord credentials not found in connector config for connector {connector_id}",
"Missing Discord credentials",
{"error_type": "MissingCredentials"},
)
return 0, "Discord credentials not found in connector config"
# Calculate date range # Calculate date range
if start_date is None or end_date is None: if start_date is None or end_date is None:
@ -135,32 +169,63 @@ async def index_discord_messages(
if start_date is None: if start_date is None:
start_date_iso = calculated_start_date.isoformat() start_date_iso = calculated_start_date.isoformat()
else: else:
# Convert YYYY-MM-DD to ISO format # Validate and convert YYYY-MM-DD to ISO format
try:
start_date_iso = ( start_date_iso = (
datetime.strptime(start_date, "%Y-%m-%d") datetime.strptime(start_date, "%Y-%m-%d")
.replace(tzinfo=UTC) .replace(tzinfo=UTC)
.isoformat() .isoformat()
) )
except ValueError as e:
logger.warning(
f"Invalid start_date format '{start_date}', using calculated start date: {e!s}"
)
start_date_iso = calculated_start_date.isoformat()
if end_date is None: if end_date is None:
end_date_iso = calculated_end_date.isoformat() end_date_iso = calculated_end_date.isoformat()
else: else:
# Convert YYYY-MM-DD to ISO format # Validate and convert YYYY-MM-DD to ISO format
try:
end_date_iso = ( end_date_iso = (
datetime.strptime(end_date, "%Y-%m-%d") datetime.strptime(end_date, "%Y-%m-%d")
.replace(tzinfo=UTC) .replace(tzinfo=UTC)
.isoformat() .isoformat()
) )
except ValueError as e:
logger.warning(
f"Invalid end_date format '{end_date}', using calculated end date: {e!s}"
)
end_date_iso = calculated_end_date.isoformat()
else: else:
# Convert provided dates to ISO format for Discord API # Convert provided dates to ISO format for Discord API
try:
start_date_iso = ( start_date_iso = (
datetime.strptime(start_date, "%Y-%m-%d") datetime.strptime(start_date, "%Y-%m-%d")
.replace(tzinfo=UTC) .replace(tzinfo=UTC)
.isoformat() .isoformat()
) )
except ValueError as e:
await task_logger.log_task_failure(
log_entry,
f"Invalid start_date format: {start_date}",
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."
try:
end_date_iso = ( 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(
log_entry,
f"Invalid end_date format: {end_date}",
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."
logger.info( logger.info(
f"Indexing Discord messages from {start_date_iso} to {end_date_iso}" f"Indexing Discord messages from {start_date_iso} to {end_date_iso}"

View file

@ -8,7 +8,6 @@ from google.oauth2.credentials import Credentials
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.google_calendar_connector import GoogleCalendarConnector from app.connectors.google_calendar_connector import GoogleCalendarConnector
from app.db import Document, DocumentType, SearchSourceConnectorType from app.db import Document, DocumentType, SearchSourceConnectorType
from app.services.llm_service import get_user_long_context_llm from app.services.llm_service import get_user_long_context_llm
@ -84,15 +83,52 @@ async def index_google_calendar_events(
return 0, f"Connector with ID {connector_id} not found" return 0, f"Connector with ID {connector_id} not found"
# Get the Google Calendar credentials from the connector config # Get the Google Calendar credentials from the connector config
exp = connector.config.get("expiry").replace("Z", "") config_data = connector.config
# Decrypt sensitive credentials if encrypted (for backward compatibility)
from app.config import config
from app.utils.oauth_security import TokenEncryption
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("token"):
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
logger.info(
f"Decrypted Google Calendar credentials for connector {connector_id}"
)
except Exception as e:
await task_logger.log_task_failure(
log_entry,
f"Failed to decrypt Google Calendar credentials for connector {connector_id}: {e!s}",
"Credential decryption failed",
{"error_type": "CredentialDecryptionError"},
)
return 0, f"Failed to decrypt Google Calendar credentials: {e!s}"
exp = config_data.get("expiry", "").replace("Z", "")
credentials = Credentials( credentials = Credentials(
token=connector.config.get("token"), token=config_data.get("token"),
refresh_token=connector.config.get("refresh_token"), refresh_token=config_data.get("refresh_token"),
token_uri=connector.config.get("token_uri"), token_uri=config_data.get("token_uri"),
client_id=connector.config.get("client_id"), client_id=config_data.get("client_id"),
client_secret=connector.config.get("client_secret"), client_secret=config_data.get("client_secret"),
scopes=connector.config.get("scopes"), scopes=config_data.get("scopes"),
expiry=datetime.fromisoformat(exp), expiry=datetime.fromisoformat(exp) if exp else None,
) )
if ( if (
@ -122,6 +158,12 @@ async def index_google_calendar_events(
connector_id=connector_id, connector_id=connector_id,
) )
# Handle 'undefined' string from frontend (treat as None)
if start_date == "undefined" or start_date == "":
start_date = None
if end_date == "undefined" or end_date == "":
end_date = None
# Calculate date range # Calculate date range
if start_date is None or end_date is None: if start_date is None or end_date is None:
# Fall back to calculating dates based on last_indexed_at # Fall back to calculating dates based on last_indexed_at

View file

@ -5,6 +5,7 @@ import logging
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.google_drive import ( from app.connectors.google_drive import (
GoogleDriveClient, GoogleDriveClient,
categorize_change, categorize_change,
@ -87,6 +88,26 @@ async def index_google_drive_files(
{"stage": "client_initialization"}, {"stage": "client_initialization"},
) )
# Check if credentials are encrypted (only when explicitly marked)
token_encrypted = connector.config.get("_token_encrypted", False)
if token_encrypted:
# Credentials are explicitly marked as encrypted, will be decrypted during client initialization
if not config.SECRET_KEY:
await task_logger.log_task_failure(
log_entry,
f"SECRET_KEY not configured but credentials are marked as encrypted for connector {connector_id}",
"Missing SECRET_KEY for token decryption",
{"error_type": "MissingSecretKey"},
)
return (
0,
"SECRET_KEY not configured but credentials are marked as encrypted",
)
logger.info(
f"Google Drive credentials are encrypted for connector {connector_id}, will decrypt during client initialization"
)
# If _token_encrypted is False or not set, treat credentials as plaintext
drive_client = GoogleDriveClient(session, connector_id) drive_client = GoogleDriveClient(session, connector_id)
if not folder_id: if not folder_id:
@ -249,6 +270,26 @@ async def index_google_drive_single_file(
{"stage": "client_initialization"}, {"stage": "client_initialization"},
) )
# Check if credentials are encrypted (only when explicitly marked)
token_encrypted = connector.config.get("_token_encrypted", False)
if token_encrypted:
# Credentials are explicitly marked as encrypted, will be decrypted during client initialization
if not config.SECRET_KEY:
await task_logger.log_task_failure(
log_entry,
f"SECRET_KEY not configured but credentials are marked as encrypted for connector {connector_id}",
"Missing SECRET_KEY for token decryption",
{"error_type": "MissingSecretKey"},
)
return (
0,
"SECRET_KEY not configured but credentials are marked as encrypted",
)
logger.info(
f"Google Drive credentials are encrypted for connector {connector_id}, will decrypt during client initialization"
)
# If _token_encrypted is False or not set, treat credentials as plaintext
drive_client = GoogleDriveClient(session, connector_id) drive_client = GoogleDriveClient(session, connector_id)
# Fetch the file metadata # Fetch the file metadata

View file

@ -8,7 +8,6 @@ from google.oauth2.credentials import Credentials
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import config
from app.connectors.google_gmail_connector import GoogleGmailConnector from app.connectors.google_gmail_connector import GoogleGmailConnector
from app.db import ( from app.db import (
Document, Document,
@ -88,9 +87,47 @@ async def index_google_gmail_messages(
) )
return 0, error_msg return 0, error_msg
# Create credentials from connector config # Get the Google Gmail credentials from the connector config
config_data = connector.config config_data = connector.config
exp = config_data.get("expiry").replace("Z", "")
# Decrypt sensitive credentials if encrypted (for backward compatibility)
from app.config import config
from app.utils.oauth_security import TokenEncryption
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("token"):
config_data["token"] = token_encryption.decrypt_token(
config_data["token"]
)
if config_data.get("refresh_token"):
config_data["refresh_token"] = token_encryption.decrypt_token(
config_data["refresh_token"]
)
if config_data.get("client_secret"):
config_data["client_secret"] = token_encryption.decrypt_token(
config_data["client_secret"]
)
logger.info(
f"Decrypted Google Gmail credentials for connector {connector_id}"
)
except Exception as e:
await task_logger.log_task_failure(
log_entry,
f"Failed to decrypt Google Gmail credentials for connector {connector_id}: {e!s}",
"Credential decryption failed",
{"error_type": "CredentialDecryptionError"},
)
return 0, f"Failed to decrypt Google Gmail credentials: {e!s}"
exp = config_data.get("expiry", "")
if exp:
exp = exp.replace("Z", "")
credentials = Credentials( credentials = Credentials(
token=config_data.get("token"), token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"), refresh_token=config_data.get("refresh_token"),
@ -98,7 +135,7 @@ async def index_google_gmail_messages(
client_id=config_data.get("client_id"), client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"), client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []), scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp), expiry=datetime.fromisoformat(exp) if exp else None,
) )
if ( if (

View file

@ -92,25 +92,34 @@ async def index_linear_issues(
f"Connector with ID {connector_id} not found or is not a Linear connector", f"Connector with ID {connector_id} not found or is not a Linear connector",
) )
# Get the Linear token from the connector config # Check if access_token exists (support both new OAuth format and old API key format)
linear_token = connector.config.get("LINEAR_API_KEY") if not connector.config.get("access_token") and not connector.config.get(
if not linear_token: "LINEAR_API_KEY"
):
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, log_entry,
f"Linear API token not found in connector config for connector {connector_id}", f"Linear access token not found in connector config for connector {connector_id}",
"Missing Linear token", "Missing Linear access token",
{"error_type": "MissingToken"}, {"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 # Initialize Linear client with internal refresh capability
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, log_entry,
f"Initializing Linear client for connector {connector_id}", f"Initializing Linear client for connector {connector_id}",
{"stage": "client_initialization"}, {"stage": "client_initialization"},
) )
linear_client = LinearConnector(token=linear_token) # Create connector with session and connector_id for internal refresh
# Token refresh will happen automatically when needed
linear_client = LinearConnector(session=session, connector_id=connector_id)
# Handle 'undefined' string from frontend (treat as None)
if start_date == "undefined" or start_date == "":
start_date = None
if end_date == "undefined" or end_date == "":
end_date = None
# Calculate date range # Calculate date range
start_date_str, end_date_str = calculate_date_range( start_date_str, end_date_str = calculate_date_range(
@ -131,7 +140,7 @@ async def index_linear_issues(
# Get issues within date range # Get issues within date range
try: try:
issues, error = linear_client.get_issues_by_date_range( issues, error = await linear_client.get_issues_by_date_range(
start_date=start_date_str, end_date=end_date_str, include_comments=True start_date=start_date_str, end_date=end_date_str, include_comments=True
) )

View file

@ -2,7 +2,7 @@
Notion connector indexer. Notion connector indexer.
""" """
from datetime import datetime, timedelta from datetime import datetime
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -20,6 +20,7 @@ from app.utils.document_converters import (
from .base import ( from .base import (
build_document_metadata_string, build_document_metadata_string,
calculate_date_range,
check_document_by_unique_identifier, check_document_by_unique_identifier,
get_connector_by_id, get_connector_by_id,
get_current_timestamp, get_current_timestamp,
@ -91,18 +92,19 @@ async def index_notion_pages(
f"Connector with ID {connector_id} not found or is not a Notion connector", f"Connector with ID {connector_id} not found or is not a Notion connector",
) )
# Get the Notion token from the connector config # Check if access_token exists (support both new OAuth format and old integration token format)
notion_token = connector.config.get("NOTION_INTEGRATION_TOKEN") if not connector.config.get("access_token") and not connector.config.get(
if not notion_token: "NOTION_INTEGRATION_TOKEN"
):
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, log_entry,
f"Notion integration token not found in connector config for connector {connector_id}", f"Notion access token not found in connector config for connector {connector_id}",
"Missing Notion token", "Missing Notion access token",
{"error_type": "MissingToken"}, {"error_type": "MissingToken"},
) )
return 0, "Notion integration token not found in connector config" return 0, "Notion access token not found in connector config"
# Initialize Notion client # Initialize Notion client with internal refresh capability
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, log_entry,
f"Initializing Notion client for connector {connector_id}", f"Initializing Notion client for connector {connector_id}",
@ -111,40 +113,30 @@ async def index_notion_pages(
logger.info(f"Initializing Notion client for connector {connector_id}") logger.info(f"Initializing Notion client for connector {connector_id}")
# Calculate date range # Handle 'undefined' string from frontend (treat as None)
if start_date is None or end_date is None: if start_date == "undefined" or start_date == "":
# Fall back to calculating dates start_date = None
calculated_end_date = datetime.now() if end_date == "undefined" or end_date == "":
calculated_start_date = calculated_end_date - timedelta( end_date = None
days=365
) # Check for last 1 year of pages
# Use calculated dates if not provided # Calculate date range using the shared utility function
if start_date is None: start_date_str, end_date_str = calculate_date_range(
start_date_iso = calculated_start_date.strftime("%Y-%m-%dT%H:%M:%SZ") connector, start_date, end_date, default_days_back=365
else: )
# Convert YYYY-MM-DD to ISO format
start_date_iso = datetime.strptime(start_date, "%Y-%m-%d").strftime( # Convert YYYY-MM-DD to ISO format for Notion API
start_date_iso = datetime.strptime(start_date_str, "%Y-%m-%d").strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
end_date_iso = datetime.strptime(end_date_str, "%Y-%m-%d").strftime(
"%Y-%m-%dT%H:%M:%SZ" "%Y-%m-%dT%H:%M:%SZ"
) )
if end_date is None: # Create connector with session and connector_id for internal refresh
end_date_iso = calculated_end_date.strftime("%Y-%m-%dT%H:%M:%SZ") # Token refresh will happen automatically when needed
else: notion_client = NotionHistoryConnector(
# Convert YYYY-MM-DD to ISO format session=session, connector_id=connector_id
end_date_iso = datetime.strptime(end_date, "%Y-%m-%d").strftime(
"%Y-%m-%dT%H:%M:%SZ"
) )
else:
# Convert provided dates to ISO format for Notion API
start_date_iso = datetime.strptime(start_date, "%Y-%m-%d").strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
end_date_iso = datetime.strptime(end_date, "%Y-%m-%d").strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
notion_client = NotionHistoryConnector(token=notion_token)
logger.info(f"Fetching Notion pages from {start_date_iso} to {end_date_iso}") logger.info(f"Fetching Notion pages from {start_date_iso} to {end_date_iso}")

View file

@ -92,25 +92,24 @@ async def index_slack_messages(
f"Connector with ID {connector_id} not found or is not a Slack connector", f"Connector with ID {connector_id} not found or is not a Slack connector",
) )
# Get the Slack token from the connector config # Note: Token handling is now done automatically by SlackHistory
slack_token = connector.config.get("SLACK_BOT_TOKEN") # with auto-refresh support. We just need to pass session and connector_id.
if not slack_token:
await task_logger.log_task_failure(
log_entry,
f"Slack token not found in connector config for connector {connector_id}",
"Missing Slack token",
{"error_type": "MissingToken"},
)
return 0, "Slack token not found in connector config"
# Initialize Slack client # Initialize Slack client with auto-refresh support
await task_logger.log_task_progress( await task_logger.log_task_progress(
log_entry, log_entry,
f"Initializing Slack client for connector {connector_id}", f"Initializing Slack client for connector {connector_id}",
{"stage": "client_initialization"}, {"stage": "client_initialization"},
) )
slack_client = SlackHistory(token=slack_token) # Use the new pattern with session and connector_id for auto-refresh
slack_client = SlackHistory(session=session, connector_id=connector_id)
# Handle 'undefined' string from frontend (treat as None)
if start_date == "undefined" or start_date == "":
start_date = None
if end_date == "undefined" or end_date == "":
end_date = None
# Calculate date range # Calculate date range
await task_logger.log_task_progress( await task_logger.log_task_progress(
@ -141,7 +140,7 @@ async def index_slack_messages(
# Get all channels # Get all channels
try: try:
channels = slack_client.get_all_channels() channels = await slack_client.get_all_channels()
except Exception as e: except Exception as e:
await task_logger.log_task_failure( await task_logger.log_task_failure(
log_entry, log_entry,
@ -190,7 +189,7 @@ async def index_slack_messages(
continue continue
# Get messages for this channel # Get messages for this channel
messages, error = slack_client.get_history_by_date_range( messages, error = await slack_client.get_history_by_date_range(
channel_id=channel_id, channel_id=channel_id,
start_date=start_date_str, start_date=start_date_str,
end_date=end_date_str, end_date=end_date_str,
@ -223,7 +222,7 @@ async def index_slack_messages(
]: ]:
continue continue
formatted_msg = slack_client.format_message( formatted_msg = await slack_client.format_message(
msg, include_user_info=True msg, include_user_info=True
) )
formatted_messages.append(formatted_msg) formatted_messages.append(formatted_msg)

View file

@ -0,0 +1,210 @@
"""
OAuth Security Utilities.
Provides secure state parameter generation/validation and token encryption
for OAuth 2.0 flows.
"""
import base64
import hashlib
import hmac
import json
import logging
import time
from uuid import UUID
from cryptography.fernet import Fernet
from fastapi import HTTPException
logger = logging.getLogger(__name__)
class OAuthStateManager:
"""Manages secure OAuth state parameters with HMAC signatures."""
def __init__(self, secret_key: str, max_age_seconds: int = 600):
"""
Initialize OAuth state manager.
Args:
secret_key: Secret key for HMAC signing (should be SECRET_KEY from config)
max_age_seconds: Maximum age of state parameter in seconds (default 10 minutes)
"""
if not secret_key:
raise ValueError("secret_key is required for OAuth state management")
self.secret_key = secret_key
self.max_age_seconds = max_age_seconds
def generate_secure_state(
self, space_id: int, user_id: UUID, **extra_fields
) -> str:
"""
Generate cryptographically signed state parameter.
Args:
space_id: The search space ID
user_id: The user ID
**extra_fields: Additional fields to include in state (e.g., code_verifier for PKCE)
Returns:
Base64-encoded state parameter with HMAC signature
"""
timestamp = int(time.time())
state_payload = {
"space_id": space_id,
"user_id": str(user_id),
"timestamp": timestamp,
}
# Add any extra fields (e.g., code_verifier for PKCE)
state_payload.update(extra_fields)
# Create signature
payload_str = json.dumps(state_payload, sort_keys=True)
signature = hmac.new(
self.secret_key.encode(),
payload_str.encode(),
hashlib.sha256,
).hexdigest()
# Include signature in state
state_payload["signature"] = signature
state_encoded = base64.urlsafe_b64encode(
json.dumps(state_payload).encode()
).decode()
return state_encoded
def validate_state(self, state: str) -> dict:
"""
Validate and decode state parameter with signature verification.
Args:
state: The state parameter from OAuth callback
Returns:
Decoded state data (space_id, user_id, timestamp)
Raises:
HTTPException: If state is invalid, expired, or tampered with
"""
try:
decoded = base64.urlsafe_b64decode(state.encode()).decode()
data = json.loads(decoded)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Invalid state format: {e!s}"
) from e
# Verify signature exists
signature = data.pop("signature", None)
if not signature:
raise HTTPException(status_code=400, detail="Missing state signature")
# Verify signature
payload_str = json.dumps(data, sort_keys=True)
expected_signature = hmac.new(
self.secret_key.encode(),
payload_str.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected_signature):
raise HTTPException(
status_code=400, detail="Invalid state signature - possible tampering"
)
# Verify timestamp (prevent replay attacks)
timestamp = data.get("timestamp", 0)
current_time = time.time()
age = current_time - timestamp
if age < 0:
raise HTTPException(status_code=400, detail="Invalid state timestamp")
if age > self.max_age_seconds:
raise HTTPException(
status_code=400,
detail="State parameter expired. Please try again.",
)
return data
class TokenEncryption:
"""Encrypt/decrypt sensitive OAuth tokens for storage."""
def __init__(self, secret_key: str):
"""
Initialize token encryption.
Args:
secret_key: Secret key for encryption (should be SECRET_KEY from config)
"""
if not secret_key:
raise ValueError("secret_key is required for token encryption")
# Derive Fernet key from secret using SHA256
# Note: In production, consider using HKDF for key derivation
key = base64.urlsafe_b64encode(hashlib.sha256(secret_key.encode()).digest())
try:
self.cipher = Fernet(key)
except Exception as e:
raise ValueError(f"Failed to initialize encryption cipher: {e!s}") from e
def encrypt_token(self, token: str) -> str:
"""
Encrypt a token for storage.
Args:
token: Plaintext token to encrypt
Returns:
Encrypted token string
"""
if not token:
return token
try:
return self.cipher.encrypt(token.encode()).decode()
except Exception as e:
logger.error(f"Failed to encrypt token: {e!s}")
raise ValueError(f"Token encryption failed: {e!s}") from e
def decrypt_token(self, encrypted_token: str) -> str:
"""
Decrypt a stored token.
Args:
encrypted_token: Encrypted token string
Returns:
Decrypted plaintext token
"""
if not encrypted_token:
return encrypted_token
try:
return self.cipher.decrypt(encrypted_token.encode()).decode()
except Exception as e:
logger.error(f"Failed to decrypt token: {e!s}")
raise ValueError(f"Token decryption failed: {e!s}") from e
def is_encrypted(self, token: str) -> bool:
"""
Check if a token appears to be encrypted.
Args:
token: Token string to check
Returns:
True if token appears encrypted, False otherwise
"""
if not token:
return False
# Encrypted tokens are base64-encoded and have specific format
# This is a heuristic check - encrypted tokens are longer and base64-like
try:
# Try to decode as base64
base64.urlsafe_b64decode(token.encode())
# If it's base64 and reasonably long, likely encrypted
return len(token) > 20
except Exception:
return False

View file

@ -513,11 +513,22 @@ def validate_connector_config(
], ],
"validators": {}, "validators": {},
}, },
"SLACK_CONNECTOR": {"required": ["SLACK_BOT_TOKEN"], "validators": {}}, # "SLACK_CONNECTOR": {
"NOTION_CONNECTOR": { # "required": [], # OAuth uses bot_token (encrypted), legacy uses SLACK_BOT_TOKEN
"required": ["NOTION_INTEGRATION_TOKEN"], # "optional": [
"validators": {}, # "bot_token",
}, # "SLACK_BOT_TOKEN",
# "bot_user_id",
# "team_id",
# "team_name",
# "token_type",
# "expires_in",
# "expires_at",
# "scope",
# "_token_encrypted",
# ],
# "validators": {},
# },
"GITHUB_CONNECTOR": { "GITHUB_CONNECTOR": {
"required": ["GITHUB_PAT", "repo_full_names"], "required": ["GITHUB_PAT", "repo_full_names"],
"validators": { "validators": {
@ -526,8 +537,7 @@ def validate_connector_config(
) )
}, },
}, },
"LINEAR_CONNECTOR": {"required": ["LINEAR_API_KEY"], "validators": {}}, # "DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}},
"DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}},
"JIRA_CONNECTOR": { "JIRA_CONNECTOR": {
"required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"],
"validators": { "validators": {

View file

@ -20,7 +20,7 @@ import {
} from "@/atoms/chat/mentioned-documents.atom"; } from "@/atoms/chat/mentioned-documents.atom";
import { import {
clearPlanOwnerRegistry, clearPlanOwnerRegistry,
extractWriteTodosFromContent, // extractWriteTodosFromContent,
hydratePlanStateAtom, hydratePlanStateAtom,
} from "@/atoms/chat/plan-state.atom"; } from "@/atoms/chat/plan-state.atom";
import { Thread } from "@/components/assistant-ui/thread"; import { Thread } from "@/components/assistant-ui/thread";
@ -30,7 +30,7 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast"; import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
import { import {
@ -199,7 +199,7 @@ const TOOLS_WITH_UI = new Set([
"link_preview", "link_preview",
"display_image", "display_image",
"scrape_webpage", "scrape_webpage",
"write_todos", // "write_todos", // Disabled for now
]); ]);
/** /**
@ -291,10 +291,11 @@ export default function NewChatPage() {
restoredThinkingSteps.set(`msg-${msg.id}`, steps); restoredThinkingSteps.set(`msg-${msg.id}`, steps);
} }
// Hydrate write_todos plan state from persisted tool calls // Hydrate write_todos plan state from persisted tool calls
const writeTodosCalls = extractWriteTodosFromContent(msg.content); // Disabled for now
for (const todoData of writeTodosCalls) { // const writeTodosCalls = extractWriteTodosFromContent(msg.content);
hydratePlanState(todoData); // for (const todoData of writeTodosCalls) {
} // hydratePlanState(todoData);
// }
} }
if (msg.role === "user") { if (msg.role === "user") {
const docs = extractMentionedDocuments(msg.content); const docs = extractMentionedDocuments(msg.content);
@ -911,7 +912,7 @@ export default function NewChatPage() {
<LinkPreviewToolUI /> <LinkPreviewToolUI />
<DisplayImageToolUI /> <DisplayImageToolUI />
<ScrapeWebpageToolUI /> <ScrapeWebpageToolUI />
<WriteTodosToolUI /> {/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden"> <div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
<Thread <Thread
messageThinkingSteps={messageThinkingSteps} messageThinkingSteps={messageThinkingSteps}

View file

@ -189,7 +189,7 @@ export const ConnectorIndicator: FC = () => {
)} )}
</TooltipIconButton> </TooltipIconButton>
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[90vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-6 sm:[&>button]:right-12 [&>button]:top-8 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5"> <DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
{/* YouTube Crawler View - shown when adding YouTube videos */} {/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? ( {isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} /> <YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
@ -272,7 +272,7 @@ export const ConnectorIndicator: FC = () => {
{/* Content */} {/* Content */}
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto" onScroll={handleScroll}> <div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-6 sm:px-12 py-6 sm:py-8 pb-16 sm:pb-16"> <div className="px-4 sm:px-12 py-4 sm:py-8 pb-12 sm:pb-16">
<TabsContent value="all" className="m-0"> <TabsContent value="all" className="m-0">
<AllConnectorsTab <AllConnectorsTab
searchQuery={searchQuery} searchQuery={searchQuery}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { IconBrandYoutube } from "@tabler/icons-react"; import { IconBrandYoutube } from "@tabler/icons-react";
import { format } from "date-fns"; import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { FileText, Loader2 } from "lucide-react"; import { FileText, Loader2 } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -49,6 +49,45 @@ function formatDocumentCount(count: number | undefined): string {
return `${m.replace(/\.0$/, "")}M docs`; return `${m.replace(/\.0$/, "")}M docs`;
} }
/**
* Format last indexed date with contextual messages
* Examples: "Just now", "10 minutes ago", "Today at 2:30 PM", "Yesterday at 3:45 PM", "3 days ago", "Jan 15, 2026"
*/
function formatLastIndexedDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
return format(date, "MMM d, yyyy");
}
export const ConnectorCard: FC<ConnectorCardProps> = ({ export const ConnectorCard: FC<ConnectorCardProps> = ({
id, id,
title, title,
@ -86,13 +125,13 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
// Show last indexed date for connected connectors // Show last indexed date for connected connectors
if (lastIndexedAt) { if (lastIndexedAt) {
return ( return (
<span className="whitespace-nowrap"> <span className="whitespace-nowrap text-[10px]">
Last indexed: {format(new Date(lastIndexedAt), "MMM d, yyyy")} Last indexed: {formatLastIndexedDate(lastIndexedAt)}
</span> </span>
); );
} }
// Fallback for connected but never indexed // Fallback for connected but never indexed
return <span className="whitespace-nowrap">Never indexed</span>; return <span className="whitespace-nowrap text-[10px]">Never indexed</span>;
} }
return description; return description;
@ -113,9 +152,9 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[14px] font-semibold leading-tight">{title}</span> <span className="text-[14px] font-semibold leading-tight">{title}</span>
</div> </div>
<div className="text-[11px] text-muted-foreground mt-1">{getStatusContent()}</div> <div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && ( {isConnected && documentCount !== undefined && (
<p className="text-[11px] text-muted-foreground mt-0.5"> <p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)} {formatDocumentCount(documentCount)}
</p> </p>
)} )}
@ -130,12 +169,10 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
!isConnected && "shadow-xs" !isConnected && "shadow-xs"
)} )}
onClick={isConnected ? onManage : onConnect} onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || isIndexing} disabled={isConnecting}
> >
{isConnecting ? ( {isConnecting ? (
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
) : isIndexing ? (
"Syncing..."
) : isConnected ? ( ) : isConnected ? (
"Manage" "Manage"
) : id === "youtube-crawler" ? ( ) : id === "youtube-crawler" ? (

View file

@ -24,20 +24,20 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
return ( return (
<div <div
className={cn( className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10", "flex-shrink-0 px-4 sm:px-12 pt-5 sm:pt-10 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-xl bg-muted/50 backdrop-blur-md" isScrolled && "shadow-xl bg-muted/50 backdrop-blur-md"
)} )}
> >
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl sm:text-3xl font-semibold tracking-tight"> <DialogTitle className="text-xl sm:text-3xl font-semibold tracking-tight">
Connectors Connectors
</DialogTitle> </DialogTitle>
<DialogDescription className="text-sm sm:text-base text-muted-foreground/80 mt-1 sm:mt-1.5"> <DialogDescription className="text-xs sm:text-base text-muted-foreground/80 mt-1 sm:mt-1.5">
Search across all your apps and data in one place. Search across all your apps and data in one place.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-6 sm:gap-8 mt-6 sm:mt-8 border-b border-border/80 dark:border-white/5"> <div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-4 sm:gap-8 mt-4 sm:mt-8 border-b border-border/80 dark:border-white/5">
<TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start"> <TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start">
<TabsTrigger <TabsTrigger
value="all" value="all"
@ -63,7 +63,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<div className="w-full sm:w-72 sm:pb-1"> <div className="w-full sm:w-72 sm:pb-1">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground/60" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500" />
<input <input
type="text" type="text"
placeholder="Search" placeholder="Search"
@ -78,7 +78,7 @@ export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
<button <button
type="button" type="button"
onClick={() => onSearchChange("")} onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
aria-label="Clear search" aria-label="Clear search"
> >
<X className="size-4" /> <X className="size-4" />

View file

@ -50,6 +50,9 @@ export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -256,6 +256,9 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Info, Webhook } from "lucide-react"; import { Webhook } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useRef } from "react"; import { useRef } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";

View file

@ -209,6 +209,9 @@ export const ClickUpConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmittin
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -263,6 +263,9 @@ export const ConfluenceConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmit
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -1,406 +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 discordConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z.string().min(10, {
message: "Discord Bot Token is required and must be valid.",
}),
});
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
export const DiscordConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<DiscordConnectorFormValues>({
resolver: zodResolver(discordConnectorFormSchema),
defaultValues: {
name: "Discord Connector",
bot_token: "",
},
});
const handleSubmit = async (values: DiscordConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.DISCORD_CONNECTOR,
config: {
DISCORD_BOT_TOKEN: values.bot_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 (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Bot Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Discord Bot Token to use this connector. You can create one from{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Discord Developer Portal
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="discord-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Discord Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Discord Bot Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Bot Token"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Discord Bot Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.DISCORD_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Discord integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.DISCORD_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Discord connector uses the Discord API to fetch messages from all accessible
channels that the bot token has access to within a server.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves messages that have been
updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Bot Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create a Discord application and bot to get a bot token. The bot
needs read access to channels and messages.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create a Discord Application
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://discord.com/developers/applications
</a>
</li>
<li>
Click <strong>New Application</strong>
</li>
<li>
Enter an application name and click <strong>Create</strong>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Create a Bot
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Navigate to <strong>Bot</strong> in the sidebar
</li>
<li>
Click <strong>Add Bot</strong> and confirm
</li>
<li>
Under <strong>Privileged Gateway Intents</strong>, enable:
<ul className="list-disc pl-5 mt-1 space-y-1">
<li>
<code className="bg-muted px-1 py-0.5 rounded">
MESSAGE CONTENT INTENT
</code>{" "}
- Required to read message content
</li>
</ul>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Get Bot Token and Invite Bot
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Under <strong>Token</strong>, click <strong>Reset Token</strong> and copy
the token
</li>
<li>
Navigate to <strong>OAuth2 URL Generator</strong>
</li>
<li>
Select <strong>bot</strong> scope and <strong>Read Messages</strong>{" "}
permission
</li>
<li>Copy the generated URL and open it in your browser</li>
<li>Select your server and authorize the bot</li>
</ol>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Discord</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Bot Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Discord messages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Discord connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Messages from all accessible channels</li>
<li>Direct messages (if bot has access)</li>
<li>Message timestamps and metadata</li>
<li>Thread replies and conversations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -616,6 +616,9 @@ export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSub
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -269,6 +269,9 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -262,6 +262,9 @@ export const JiraConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -1,396 +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 linearConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z
.string()
.min(10, {
message: "Linear API Key is required and must be valid.",
})
.regex(/^lin_api_/, {
message: "Linear API Key should start with 'lin_api_'",
}),
});
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
export const LinearConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<LinearConnectorFormValues>({
resolver: zodResolver(linearConnectorFormSchema),
defaultValues: {
name: "Linear Connector",
api_key: "",
},
});
const handleSubmit = async (values: LinearConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.LINEAR_CONNECTOR,
config: {
LINEAR_API_KEY: values.api_key,
},
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 (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Linear API Key to use this connector. You can create one from{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Linear API Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="linear-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Linear Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Linear API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="lin_api_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Linear API Key will be encrypted and stored securely. It typically starts
with "lin_api_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.LINEAR_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Linear integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.LINEAR_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Linear connector uses the Linear GraphQL API to fetch all issues and comments
that the API key has access to within a workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves issues and comments that have
been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Read-Only Access is Sufficient
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You only need a read-only API key for this connector to work. This limits the
permissions to just reading your Linear data.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API key
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your Linear account</li>
<li>
Navigate to{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://linear.app/settings/api
</a>{" "}
in your browser.
</li>
<li>Alternatively, click on your profile picture Settings API</li>
<li>
Click the <strong>+ New API key</strong> button.
</li>
<li>Enter a description for your key (like "Search Connector").</li>
<li>Select "Read-only" as the permission.</li>
<li>
Click <strong>Create</strong> to generate the API key.
</li>
<li>
Copy the generated API key that starts with 'lin_api_' as it will only be
shown once.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API key will have access to all issues and comments that your user account
can see. If you're creating the key as an admin, it will have access to all
issues in the workspace.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only issues and comments will be indexed. Linear attachments and linked
files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Linear</strong>{" "}
Connector.
</li>
<li>
Place the <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Linear issues will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Linear connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments</li>
<li>Issue status and metadata</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -209,6 +209,9 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
<SelectValue placeholder="Select frequency" /> <SelectValue placeholder="Select frequency" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="z-[100]"> <SelectContent className="z-[100]">
<SelectItem value="5" className="text-xs sm:text-sm">
Every 5 minutes
</SelectItem>
<SelectItem value="15" className="text-xs sm:text-sm"> <SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes Every 15 minutes
</SelectItem> </SelectItem>

View file

@ -1,396 +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 notionConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
integration_token: z.string().min(10, {
message: "Notion Integration Token is required and must be valid.",
}),
});
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
export const NotionConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<NotionConnectorFormValues>({
resolver: zodResolver(notionConnectorFormSchema),
defaultValues: {
name: "Notion Connector",
integration_token: "",
},
});
const handleSubmit = async (values: NotionConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.NOTION_CONNECTOR,
config: {
NOTION_INTEGRATION_TOKEN: values.integration_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 (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Integration Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Notion Integration Token to use this connector. You can create one from{" "}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Notion Integrations
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="notion-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Notion Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="integration_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Notion Integration Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ntn_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Notion Integration Token will be encrypted and stored securely. It
typically starts with "ntn_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.NOTION_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Notion integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.NOTION_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Notion connector uses the Notion API to fetch pages from all accessible
workspaces that the integration token has access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves pages that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Integration Token Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create a Notion integration and share pages with it to get access.
The integration needs read access to pages.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create a Notion Integration
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to{" "}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://www.notion.so/my-integrations
</a>
</li>
<li>
Click <strong>+ New integration</strong>
</li>
<li>Enter a name for your integration (e.g., "Search Connector")</li>
<li>Select your workspace</li>
<li>
Under <strong>Capabilities</strong>, enable <strong>Read content</strong>
</li>
<li>
Click <strong>Submit</strong> to create the integration
</li>
<li>
Copy the <strong>Internal Integration Token</strong> (starts with "ntn_")
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Share Pages with Integration
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Open the Notion pages or databases you want to index</li>
<li>
Click the <strong></strong> (three dots) menu in the top right
</li>
<li>
Select <strong>Add connections</strong> or <strong>Connections</strong>
</li>
<li>Search for and select your integration</li>
<li>Repeat for all pages you want to index</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Important</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
The integration can only access pages that have been explicitly shared with
it. Make sure to share all pages you want to index.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Integration Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Notion pages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Notion connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Page titles and content</li>
<li>Database entries and properties</li>
<li>Page metadata and properties</li>
<li>Nested pages and sub-pages</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -1,426 +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 slackConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z.string().min(10, {
message: "Slack Bot Token is required and must be valid.",
}),
});
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
export const SlackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<SlackConnectorFormValues>({
resolver: zodResolver(slackConnectorFormSchema),
defaultValues: {
name: "Slack Connector",
bot_token: "",
},
});
const handleSubmit = async (values: SlackConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.SLACK_CONNECTOR,
config: {
SLACK_BOT_TOKEN: values.bot_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 (
<div className="space-y-6 pb-6">
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Bot User OAuth Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack
app and get the token from{" "}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Slack API Dashboard
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="slack-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Slack Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Slack Bot User OAuth Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="xoxb-..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Bot User OAuth Token will be encrypted and stored securely. It typically
starts with "xoxb-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.SLACK_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Slack integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.SLACK_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Slack connector uses the Slack Web API to fetch messages from all accessible
channels that the bot token has access to within a workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves messages that have been
updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Bot User OAuth Token Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create a Slack app and install it to your workspace to get a Bot
User OAuth Token. The bot needs read access to channels and messages.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create a Slack App
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to{" "}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://api.slack.com/apps
</a>
</li>
<li>
Click <strong>Create New App</strong> and choose "From scratch"
</li>
<li>Enter an app name and select your workspace</li>
<li>
Click <strong>Create App</strong>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Configure Bot Scopes
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Navigate to <strong>OAuth & Permissions</strong> in the sidebar
</li>
<li>
Under <strong>Bot Token Scopes</strong>, add the following scopes:
<ul className="list-disc pl-5 mt-1 space-y-1">
<li>
<code className="bg-muted px-1 py-0.5 rounded">channels:read</code> -
View basic information about public channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">channels:history</code> -
View messages in public channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">groups:read</code> - View
basic information about private channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">groups:history</code> -
View messages in private channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">im:read</code> - View
basic information about direct messages
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">im:history</code> - View
messages in direct messages
</li>
</ul>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Install App to Workspace
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to <strong>Install App</strong> in the sidebar
</li>
<li>
Click <strong>Install to Workspace</strong>
</li>
<li>
Review the permissions and click <strong>Allow</strong>
</li>
<li>
Copy the <strong>Bot User OAuth Token</strong> from the "OAuth &
Permissions" page (starts with "xoxb-")
</li>
</ol>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Bot User OAuth Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Slack messages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Slack connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Messages from all accessible channels (public and private)</li>
<li>Direct messages (if bot has access)</li>
<li>Message timestamps and metadata</li>
<li>Thread replies and conversations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -4,16 +4,12 @@ import { BookStackConnectForm } from "./components/bookstack-connect-form";
import { CirclebackConnectForm } from "./components/circleback-connect-form"; import { CirclebackConnectForm } from "./components/circleback-connect-form";
import { ClickUpConnectForm } from "./components/clickup-connect-form"; import { ClickUpConnectForm } from "./components/clickup-connect-form";
import { ConfluenceConnectForm } from "./components/confluence-connect-form"; import { ConfluenceConnectForm } from "./components/confluence-connect-form";
import { DiscordConnectForm } from "./components/discord-connect-form";
import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form"; import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form";
import { GithubConnectForm } from "./components/github-connect-form"; import { GithubConnectForm } from "./components/github-connect-form";
import { JiraConnectForm } from "./components/jira-connect-form"; import { JiraConnectForm } from "./components/jira-connect-form";
import { LinearConnectForm } from "./components/linear-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form"; import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form";
import { NotionConnectForm } from "./components/notion-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form"; import { SearxngConnectForm } from "./components/searxng-connect-form";
import { SlackConnectForm } from "./components/slack-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form"; import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
export interface ConnectFormProps { export interface ConnectFormProps {
@ -51,16 +47,8 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
return LinkupApiConnectForm; return LinkupApiConnectForm;
case "BAIDU_SEARCH_API": case "BAIDU_SEARCH_API":
return BaiduSearchApiConnectForm; return BaiduSearchApiConnectForm;
case "LINEAR_CONNECTOR":
return LinearConnectForm;
case "ELASTICSEARCH_CONNECTOR": case "ELASTICSEARCH_CONNECTOR":
return ElasticsearchConnectForm; return ElasticsearchConnectForm;
case "SLACK_CONNECTOR":
return SlackConnectForm;
case "DISCORD_CONNECTOR":
return DiscordConnectForm;
case "NOTION_CONNECTOR":
return NotionConnectForm;
case "CONFLUENCE_CONNECTOR": case "CONFLUENCE_CONNECTOR":
return ConfluenceConnectForm; return ConfluenceConnectForm;
case "BOOKSTACK_CONNECTOR": case "BOOKSTACK_CONNECTOR":

View file

@ -1,88 +1,26 @@
"use client"; "use client";
import { KeyRound } from "lucide-react"; import { Info } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index"; import type { ConnectorConfigProps } from "../index";
export interface DiscordConfigProps extends ConnectorConfigProps { export interface DiscordConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
} }
export const DiscordConfig: FC<DiscordConfigProps> = ({ export const DiscordConfig: FC<DiscordConfigProps> = () => {
connector,
onConfigChange,
onNameChange,
}) => {
const [botToken, setBotToken] = useState<string>(
(connector.config?.DISCORD_BOT_TOKEN as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update bot token and name when connector changes
useEffect(() => {
const token = (connector.config?.DISCORD_BOT_TOKEN as string) || "";
setBotToken(token);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleBotTokenChange = (value: string) => {
setBotToken(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
DISCORD_BOT_TOKEN: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Connector Name */} <div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<div className="space-y-2"> <Info className="size-4" />
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Discord Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div> </div>
</div> <div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Add Bot to Servers</p>
{/* Configuration */} <p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> Before indexing, make sure the Discord bot has been added to the servers (guilds) you want to
<div className="space-y-1 sm:space-y-2"> index. The bot can only access messages from servers it's been added to. Use the OAuth
<h3 className="font-medium text-sm sm:text-base">Configuration</h3> authorization flow to add the bot to your servers.
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
Discord Bot Token
</Label>
<Input
type="password"
value={botToken}
onChange={(e) => handleBotTokenChange(e.target.value)}
placeholder="Your Bot Token"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your Discord Bot Token if needed.
</p> </p>
</div> </div>
</div> </div>

View file

@ -1,89 +0,0 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
export interface LinearConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const LinearConfig: FC<LinearConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [apiKey, setApiKey] = useState<string>((connector.config?.LINEAR_API_KEY as string) || "");
const [name, setName] = useState<string>(connector.name || "");
// Update API key and name when connector changes
useEffect(() => {
const key = (connector.config?.LINEAR_API_KEY as string) || "";
setApiKey(key);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
LINEAR_API_KEY: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Linear Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
Linear API Key
</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="Begins with lin_api_..."
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your Linear API Key if needed.
</p>
</div>
</div>
</div>
);
};

View file

@ -1,91 +0,0 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
export interface NotionConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const NotionConfig: FC<NotionConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [integrationToken, setIntegrationToken] = useState<string>(
(connector.config?.NOTION_INTEGRATION_TOKEN as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update integration token and name when connector changes
useEffect(() => {
const token = (connector.config?.NOTION_INTEGRATION_TOKEN as string) || "";
setIntegrationToken(token);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleIntegrationTokenChange = (value: string) => {
setIntegrationToken(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
NOTION_INTEGRATION_TOKEN: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Notion Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
Notion Integration Token
</Label>
<Input
type="password"
value={integrationToken}
onChange={(e) => handleIntegrationTokenChange(e.target.value)}
placeholder="Begins with secret_..."
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your Notion Integration Token if needed.
</p>
</div>
</div>
</div>
);
};

View file

@ -1,84 +1,27 @@
"use client"; "use client";
import { KeyRound } from "lucide-react"; import { Info } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index"; import type { ConnectorConfigProps } from "../index";
export interface SlackConfigProps extends ConnectorConfigProps { export interface SlackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void; onNameChange?: (name: string) => void;
} }
export const SlackConfig: FC<SlackConfigProps> = ({ connector, onConfigChange, onNameChange }) => { export const SlackConfig: FC<SlackConfigProps> = () => {
const [botToken, setBotToken] = useState<string>(
(connector.config?.SLACK_BOT_TOKEN as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update bot token and name when connector changes
useEffect(() => {
const token = (connector.config?.SLACK_BOT_TOKEN as string) || "";
setBotToken(token);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleBotTokenChange = (value: string) => {
setBotToken(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
SLACK_BOT_TOKEN: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Connector Name */} <div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
<div className="space-y-2"> <Info className="size-4" />
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Slack Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div> </div>
</div> <div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Add Bot to Channels</p>
{/* Configuration */} <p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4"> Before indexing, add the SurfSense bot to each channel you want to index. The bot can
<div className="space-y-1 sm:space-y-2"> only access messages from channels it's been added to. Type{" "}
<h3 className="font-medium text-sm sm:text-base">Configuration</h3> <code className="bg-muted px-1 py-0.5 rounded text-[9px]">/invite @SurfSense</code> in
</div> any channel to add it.
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
Slack Bot User OAuth Token
</Label>
<Input
type="password"
value={botToken}
onChange={(e) => handleBotTokenChange(e.target.value)}
placeholder="Begins with xoxb-..."
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your Bot User OAuth Token if needed.
</p> </p>
</div> </div>
</div> </div>

View file

@ -12,10 +12,8 @@ import { ElasticsearchConfig } from "./components/elasticsearch-config";
import { GithubConfig } from "./components/github-config"; import { GithubConfig } from "./components/github-config";
import { GoogleDriveConfig } from "./components/google-drive-config"; import { GoogleDriveConfig } from "./components/google-drive-config";
import { JiraConfig } from "./components/jira-config"; import { JiraConfig } from "./components/jira-config";
import { LinearConfig } from "./components/linear-config";
import { LinkupApiConfig } from "./components/linkup-api-config"; import { LinkupApiConfig } from "./components/linkup-api-config";
import { LumaConfig } from "./components/luma-config"; import { LumaConfig } from "./components/luma-config";
import { NotionConfig } from "./components/notion-config";
import { SearxngConfig } from "./components/searxng-config"; import { SearxngConfig } from "./components/searxng-config";
import { SlackConfig } from "./components/slack-config"; import { SlackConfig } from "./components/slack-config";
import { TavilyApiConfig } from "./components/tavily-api-config"; import { TavilyApiConfig } from "./components/tavily-api-config";
@ -46,8 +44,6 @@ export function getConnectorConfigComponent(
return LinkupApiConfig; return LinkupApiConfig;
case "BAIDU_SEARCH_API": case "BAIDU_SEARCH_API":
return BaiduSearchApiConfig; return BaiduSearchApiConfig;
case "LINEAR_CONNECTOR":
return LinearConfig;
case "WEBCRAWLER_CONNECTOR": case "WEBCRAWLER_CONNECTOR":
return WebcrawlerConfig; return WebcrawlerConfig;
case "ELASTICSEARCH_CONNECTOR": case "ELASTICSEARCH_CONNECTOR":
@ -56,8 +52,6 @@ export function getConnectorConfigComponent(
return SlackConfig; return SlackConfig;
case "DISCORD_CONNECTOR": case "DISCORD_CONNECTOR":
return DiscordConfig; return DiscordConfig;
case "NOTION_CONNECTOR":
return NotionConfig;
case "CONFLUENCE_CONNECTOR": case "CONFLUENCE_CONNECTOR":
return ConfluenceConfig; return ConfluenceConfig;
case "BOOKSTACK_CONNECTOR": case "BOOKSTACK_CONNECTOR":
@ -72,7 +66,7 @@ export function getConnectorConfigComponent(
return LumaConfig; return LumaConfig;
case "CIRCLEBACK_CONNECTOR": case "CIRCLEBACK_CONNECTOR":
return CirclebackConfig; return CirclebackConfig;
// OAuth connectors (Gmail, Calendar, Airtable) and others don't need special config UI // OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
default: default:
return null; return null;
} }

View file

@ -51,11 +51,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
SEARXNG_API: "searxng-connect-form", SEARXNG_API: "searxng-connect-form",
LINKUP_API: "linkup-api-connect-form", LINKUP_API: "linkup-api-connect-form",
BAIDU_SEARCH_API: "baidu-search-api-connect-form", BAIDU_SEARCH_API: "baidu-search-api-connect-form",
LINEAR_CONNECTOR: "linear-connect-form",
ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form", ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form",
SLACK_CONNECTOR: "slack-connect-form",
DISCORD_CONNECTOR: "discord-connect-form",
NOTION_CONNECTOR: "notion-connect-form",
CONFLUENCE_CONNECTOR: "confluence-connect-form", CONFLUENCE_CONNECTOR: "confluence-connect-form",
BOOKSTACK_CONNECTOR: "bookstack-connect-form", BOOKSTACK_CONNECTOR: "bookstack-connect-form",
GITHUB_CONNECTOR: "github-connect-form", GITHUB_CONNECTOR: "github-connect-form",

View file

@ -59,6 +59,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [hasMoreContent, setHasMoreContent] = useState(false); const [hasMoreContent, setHasMoreContent] = useState(false);
const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
const [isQuickIndexing, setIsQuickIndexing] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const checkScrollState = useCallback(() => { const checkScrollState = useCallback(() => {
@ -94,6 +95,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
}; };
}, [checkScrollState]); }, [checkScrollState]);
// Reset local quick indexing state when indexing completes
useEffect(() => {
if (!isIndexing) {
setIsQuickIndexing(false);
}
}, [isIndexing]);
const handleDisconnectClick = () => { const handleDisconnectClick = () => {
setShowDisconnectConfirm(true); setShowDisconnectConfirm(true);
}; };
@ -107,6 +115,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
setShowDisconnectConfirm(false); setShowDisconnectConfirm(false);
}; };
const handleQuickIndex = useCallback(() => {
if (onQuickIndex) {
setIsQuickIndexing(true);
onQuickIndex();
}
}, [onQuickIndex]);
return ( return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */} {/* Fixed Header */}
@ -146,11 +161,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={onQuickIndex} onClick={handleQuickIndex}
disabled={isIndexing || isSaving || isDisconnecting} disabled={isQuickIndexing || isIndexing || isSaving || isDisconnecting}
className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto" className="text-xs sm:text-sm bg-slate-400/10 dark:bg-white/10 hover:bg-slate-400/20 dark:hover:bg-white/20 border-slate-400/20 dark:border-white/20 w-full sm:w-auto"
> >
{isIndexing ? ( {isQuickIndexing || isIndexing ? (
<> <>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" /> <RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Indexing... Indexing...

View file

@ -2,6 +2,7 @@
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -43,6 +44,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onStartIndexing, onStartIndexing,
onSkip, onSkip,
}) => { }) => {
const searchParams = useSearchParams();
const isFromOAuth = searchParams.get("view") === "configure";
// Get connector-specific config component // Get connector-specific config component
const ConnectorConfigComponent = useMemo( const ConnectorConfigComponent = useMemo(
() => (connector ? getConnectorConfigComponent(connector.connector_type) : null), () => (connector ? getConnectorConfigComponent(connector.connector_type) : null),
@ -94,7 +98,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
isScrolled && "shadow-sm" isScrolled && "shadow-sm"
)} )}
> >
{/* Back button */} {/* Back button - only show if not from OAuth */}
{!isFromOAuth && (
<button <button
type="button" type="button"
onClick={onSkip} onClick={onSkip}
@ -103,6 +108,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<ArrowLeft className="size-4" /> <ArrowLeft className="size-4" />
Back to connectors Back to connectors
</button> </button>
)}
{/* Success header */} {/* Success header */}
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
@ -187,15 +193,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
</div> </div>
{/* Fixed Footer - Action buttons */} {/* Fixed Footer - Action buttons */}
<div className="flex-shrink-0 flex items-center justify-between px-6 sm:px-12 py-6 bg-muted"> <div className="flex-shrink-0 flex items-center justify-end px-6 sm:px-12 py-6 bg-muted">
<Button
variant="ghost"
onClick={onSkip}
disabled={isStartingIndexing}
className="text-xs sm:text-sm"
>
Skip for now
</Button>
<Button <Button
onClick={onStartIndexing} onClick={onStartIndexing}
disabled={isStartingIndexing} disabled={isStartingIndexing}

View file

@ -30,6 +30,34 @@ export const OAUTH_CONNECTORS = [
connectorType: EnumConnectorName.AIRTABLE_CONNECTOR, connectorType: EnumConnectorName.AIRTABLE_CONNECTOR,
authEndpoint: "/api/v1/auth/airtable/connector/add/", authEndpoint: "/api/v1/auth/airtable/connector/add/",
}, },
{
id: "notion-connector",
title: "Notion",
description: "Search your Notion pages",
connectorType: EnumConnectorName.NOTION_CONNECTOR,
authEndpoint: "/api/v1/auth/notion/connector/add/",
},
{
id: "linear-connector",
title: "Linear",
description: "Search issues & projects",
connectorType: EnumConnectorName.LINEAR_CONNECTOR,
authEndpoint: "/api/v1/auth/linear/connector/add/",
},
{
id: "slack-connector",
title: "Slack",
description: "Search Slack messages",
connectorType: EnumConnectorName.SLACK_CONNECTOR,
authEndpoint: "/api/v1/auth/slack/connector/add/",
},
{
id: "discord-connector",
title: "Discord",
description: "Search Discord messages",
connectorType: EnumConnectorName.DISCORD_CONNECTOR,
authEndpoint: "/api/v1/auth/discord/connector/add/",
},
] as const; ] as const;
// Content Sources (tools that extract and import content from external sources) // Content Sources (tools that extract and import content from external sources)
@ -50,24 +78,6 @@ export const CRAWLERS = [
// Non-OAuth Connectors (redirect to old connector config pages) // Non-OAuth Connectors (redirect to old connector config pages)
export const OTHER_CONNECTORS = [ export const OTHER_CONNECTORS = [
{
id: "slack-connector",
title: "Slack",
description: "Search Slack messages",
connectorType: EnumConnectorName.SLACK_CONNECTOR,
},
{
id: "discord-connector",
title: "Discord",
description: "Search Discord messages",
connectorType: EnumConnectorName.DISCORD_CONNECTOR,
},
{
id: "notion-connector",
title: "Notion",
description: "Search Notion pages",
connectorType: EnumConnectorName.NOTION_CONNECTOR,
},
{ {
id: "confluence-connector", id: "confluence-connector",
title: "Confluence", title: "Confluence",
@ -86,12 +96,6 @@ export const OTHER_CONNECTORS = [
description: "Search repositories", description: "Search repositories",
connectorType: EnumConnectorName.GITHUB_CONNECTOR, connectorType: EnumConnectorName.GITHUB_CONNECTOR,
}, },
{
id: "linear-connector",
title: "Linear",
description: "Search issues & projects",
connectorType: EnumConnectorName.LINEAR_CONNECTOR,
},
{ {
id: "jira-connector", id: "jira-connector",
title: "Jira", title: "Jira",
@ -143,7 +147,7 @@ export const OTHER_CONNECTORS = [
{ {
id: "circleback-connector", id: "circleback-connector",
title: "Circleback", title: "Circleback",
description: "Receive meeting notes via webhook", description: "Receive meeting notes, transcripts",
connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR, connectorType: EnumConnectorName.CIRCLEBACK_CONNECTOR,
}, },
] as const; ] as const;

View file

@ -39,7 +39,7 @@ export type IndexingConfigState = z.infer<typeof indexingConfigStateSchema>;
/** /**
* Schema for frequency minutes (must be one of the allowed values) * Schema for frequency minutes (must be one of the allowed values)
*/ */
export const frequencyMinutesSchema = z.enum(["15", "60", "360", "720", "1440", "10080"], { export const frequencyMinutesSchema = z.enum(["5", "15", "60", "360", "720", "1440", "10080"], {
message: "Invalid frequency value", message: "Invalid frequency value",
}); });

View file

@ -472,7 +472,7 @@ export const useConnectorDialog = () => {
} }
// Auto-start indexing for non-OAuth reindexable connectors // Auto-start indexing for non-OAuth reindexable connectors
// This only applies to non-OAuth reindexable connectors (e.g., Elasticsearch, Linear) // This only applies to non-OAuth reindexable connectors (e.g., Elasticsearch)
// Non-reindexable connectors (e.g., Tavily) have is_indexable: false, so they won't trigger this // Non-reindexable connectors (e.g., Tavily) have is_indexable: false, so they won't trigger this
// Backend will use default date ranges (365 days ago to today) if dates are not provided // Backend will use default date ranges (365 days ago to today) if dates are not provided
if (connector.is_indexable) { if (connector.is_indexable) {

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { format } from "date-fns"; import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowRight, Cable, Loader2 } from "lucide-react"; import { ArrowRight, Cable, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { FC } from "react"; import type { FC } from "react";
@ -64,6 +64,42 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
return `${m.replace(/\.0$/, "")}M docs`; return `${m.replace(/\.0$/, "")}M docs`;
}; };
// Format last indexed date with contextual messages
const formatLastIndexedDate = (dateString: string): string => {
const date = new Date(dateString);
const now = new Date();
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
// Just now (within last minute)
if (minutesAgo < 1) {
return "Just now";
}
// X minutes ago (less than 1 hour)
if (minutesAgo < 60) {
return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
}
// Today at [time]
if (isToday(date)) {
return `Today at ${format(date, "h:mm a")}`;
}
// Yesterday at [time]
if (isYesterday(date)) {
return `Yesterday at ${format(date, "h:mm a")}`;
}
// X days ago (less than 7 days)
if (daysAgo < 7) {
return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
}
// Full date for older entries
return format(date, "MMM d, yyyy");
};
// Document types that should be shown as cards (not from connectors) // Document types that should be shown as cards (not from connectors)
// These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes), // These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes),
// YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR) // YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR)
@ -148,13 +184,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
)} )}
</p> </p>
) : ( ) : (
<p className="text-[11px] text-muted-foreground mt-1"> <p className="text-[10px] text-muted-foreground mt-1 whitespace-nowrap">
{connector.last_indexed_at {connector.last_indexed_at
? `Last indexed: ${format(new Date(connector.last_indexed_at), "MMM d, yyyy")}` ? `Last indexed: ${formatLastIndexedDate(connector.last_indexed_at)}`
: "Never indexed"} : "Never indexed"}
</p> </p>
)} )}
<p className="text-[11px] text-muted-foreground mt-0.5"> <p className="text-[10px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)} {formatDocumentCount(documentCount)}
</p> </p>
</div> </div>
@ -163,9 +199,8 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
size="sm" size="sm"
className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80" className="h-8 text-[11px] px-3 rounded-lg font-medium bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
onClick={onManage ? () => onManage(connector) : undefined} onClick={onManage ? () => onManage(connector) : undefined}
disabled={isIndexing}
> >
{isIndexing ? "Syncing..." : "Manage"} Manage
</Button> </Button>
</div> </div>
); );

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { Upload } from "lucide-react";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
@ -85,6 +86,7 @@ const DocumentUploadPopupContent: FC<{
}> = ({ isOpen, onOpenChange }) => { }> = ({ isOpen, onOpenChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const router = useRouter(); const router = useRouter();
const [isAccordionExpanded, setIsAccordionExpanded] = useState(false);
if (!searchSpaceId) return null; if (!searchSpaceId) return null;
@ -95,16 +97,40 @@ const DocumentUploadPopupContent: FC<{
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-[95vw] sm:w-full h-[calc(100vh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-4 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5"> <DialogContent className="max-w-4xl w-[95vw] sm:w-full max-h-[calc(100vh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-4 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5">
<DialogTitle className="sr-only">Upload Document</DialogTitle> <DialogTitle className="sr-only">Upload Document</DialogTitle>
{/* Fixed Header */}
<div className="flex-shrink-0 px-4 sm:px-12 pt-6 sm:pt-10 transition-shadow duration-200 relative z-10">
{/* Upload header */}
<div className="flex items-center gap-2 sm:gap-4 mb-2 sm:mb-6">
<div className="flex h-10 w-10 sm:h-14 sm:w-14 items-center justify-center rounded-lg sm:rounded-xl bg-primary/10 border border-primary/20 flex-shrink-0">
<Upload className="size-5 sm:size-7 text-primary" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-lg sm:text-2xl font-semibold tracking-tight">Upload Documents</h2>
<p className="text-xs sm:text-base text-muted-foreground mt-0.5 sm:mt-1">
Upload and sync your documents to your search space
</p>
</div>
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 min-h-0 relative overflow-hidden"> <div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto"> <div className={`h-full ${isAccordionExpanded ? "overflow-y-auto" : ""}`}>
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16"> <div className="px-6 sm:px-12 pb-5 sm:pb-16">
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} /> <DocumentUploadTab
searchSpaceId={searchSpaceId}
onSuccess={handleSuccess}
onAccordionStateChange={setIsAccordionExpanded}
/>
</div> </div>
</div> </div>
{/* Bottom fade shadow */} {/* Bottom fade shadow - only show when scrolling */}
{isAccordionExpanded && (
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" /> <div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View file

@ -36,7 +36,6 @@ export const editConnectorSchema = z.object({
SEARXNG_LANGUAGE: z.string().optional(), SEARXNG_LANGUAGE: z.string().optional(),
SEARXNG_SAFESEARCH: z.string().optional(), SEARXNG_SAFESEARCH: z.string().optional(),
SEARXNG_VERIFY_SSL: z.string().optional(), SEARXNG_VERIFY_SSL: z.string().optional(),
LINEAR_API_KEY: z.string().optional(),
LINKUP_API_KEY: z.string().optional(), LINKUP_API_KEY: z.string().optional(),
DISCORD_BOT_TOKEN: z.string().optional(), DISCORD_BOT_TOKEN: z.string().optional(),
CONFLUENCE_BASE_URL: z.string().optional(), CONFLUENCE_BASE_URL: z.string().optional(),

View file

@ -12,7 +12,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types"; import type { Document, GetDocumentsResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -31,6 +31,8 @@ interface DocumentMentionPickerProps {
externalSearch?: string; externalSearch?: string;
} }
const PAGE_SIZE = 20;
function useDebounced<T>(value: T, delay = 300) { function useDebounced<T>(value: T, delay = 300) {
const [debounced, setDebounced] = useState(value); const [debounced, setDebounced] = useState(value);
useEffect(() => { useEffect(() => {
@ -52,12 +54,29 @@ export const DocumentMentionPicker = forwardRef<
const debouncedSearch = useDebounced(search, 150); const debouncedSearch = useDebounced(search, 150);
const [highlightedIndex, setHighlightedIndex] = useState(0); const [highlightedIndex, setHighlightedIndex] = useState(0);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const scrollContainerRef = useRef<HTMLDivElement>(null);
// State for pagination
const [accumulatedDocuments, setAccumulatedDocuments] = useState<Document[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Reset pagination when search or search space changes
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally reset pagination when search/space changes
useEffect(() => {
setAccumulatedDocuments([]);
setCurrentPage(0);
setHasMore(false);
setHighlightedIndex(0);
}, [debouncedSearch, searchSpaceId]);
// Query params for initial fetch (page 0)
const fetchQueryParams = useMemo( const fetchQueryParams = useMemo(
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: 0, page: 0,
page_size: 20, page_size: PAGE_SIZE,
}), }),
[searchSpaceId] [searchSpaceId]
); );
@ -66,31 +85,97 @@ export const DocumentMentionPicker = forwardRef<
return { return {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: 0, page: 0,
page_size: 20, page_size: PAGE_SIZE,
title: debouncedSearch, title: debouncedSearch,
}; };
}, [debouncedSearch, searchSpaceId]); }, [debouncedSearch, searchSpaceId]);
// Use query for fetching documents // Use query for fetching first page of documents
const { data: documents, isLoading: isDocumentsLoading } = useQuery({ const { data: documents, isLoading: isDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams), queryKey: cacheKeys.documents.withQueryParams(fetchQueryParams),
queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }), queryFn: () => documentsApiService.getDocuments({ queryParams: fetchQueryParams }),
staleTime: 3 * 60 * 1000, staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !debouncedSearch.trim(), enabled: !!searchSpaceId && !debouncedSearch.trim() && currentPage === 0,
}); });
// Searching // Searching - first page
const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({ const { data: searchedDocuments, isLoading: isSearchedDocumentsLoading } = useQuery({
queryKey: cacheKeys.documents.withQueryParams(searchQueryParams), queryKey: cacheKeys.documents.withQueryParams(searchQueryParams),
queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }),
staleTime: 3 * 60 * 1000, staleTime: 3 * 60 * 1000,
enabled: !!searchSpaceId && !!debouncedSearch.trim(), enabled: !!searchSpaceId && !!debouncedSearch.trim() && currentPage === 0,
}); });
const actualDocuments = debouncedSearch.trim() // Update accumulated documents when first page loads
? searchedDocuments?.items || [] useEffect(() => {
: documents?.items || []; if (currentPage === 0) {
const actualLoading = debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading; if (debouncedSearch.trim()) {
if (searchedDocuments) {
setAccumulatedDocuments(searchedDocuments.items);
setHasMore(searchedDocuments.has_more);
}
} else {
if (documents) {
setAccumulatedDocuments(documents.items);
setHasMore(documents.has_more);
}
}
}
}, [documents, searchedDocuments, debouncedSearch, currentPage]);
// Function to load next page
const loadNextPage = useCallback(async () => {
if (isLoadingMore || !hasMore) return;
const nextPage = currentPage + 1;
setIsLoadingMore(true);
try {
let response: GetDocumentsResponse;
if (debouncedSearch.trim()) {
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
title: debouncedSearch,
};
response = await documentsApiService.searchDocuments({ queryParams });
} else {
const queryParams = {
search_space_id: searchSpaceId,
page: nextPage,
page_size: PAGE_SIZE,
};
response = await documentsApiService.getDocuments({ queryParams });
}
setAccumulatedDocuments((prev) => [...prev, ...response.items]);
setHasMore(response.has_more);
setCurrentPage(nextPage);
} catch (error) {
console.error("Failed to load next page:", error);
} finally {
setIsLoadingMore(false);
}
}, [currentPage, hasMore, isLoadingMore, debouncedSearch, searchSpaceId]);
// Infinite scroll handler
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
// Load more when within 50px of bottom
if (scrollBottom < 50 && hasMore && !isLoadingMore) {
loadNextPage();
}
},
[hasMore, isLoadingMore, loadNextPage]
);
const actualDocuments = accumulatedDocuments;
const actualLoading =
(debouncedSearch.trim() ? isSearchedDocumentsLoading : isDocumentsLoading) && currentPage === 0;
// Track already selected document IDs // Track already selected document IDs
const selectedIds = useMemo( const selectedIds = useMemo(
@ -184,8 +269,12 @@ export const DocumentMentionPicker = forwardRef<
role="listbox" role="listbox"
tabIndex={-1} tabIndex={-1}
> >
{/* Document List - Shows max 3 items on mobile, 5 items on desktop */} {/* Document List - Shows max 5 items on mobile, 7-8 items on desktop */}
<div className="max-h-[108px] sm:max-h-[180px] overflow-y-auto"> <div
ref={scrollContainerRef}
className="max-h-[180px] sm:max-h-[280px] overflow-y-auto"
onScroll={handleScroll}
>
{actualLoading ? ( {actualLoading ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" /> <div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
@ -235,6 +324,12 @@ export const DocumentMentionPicker = forwardRef<
</button> </button>
); );
})} })}
{/* Loading indicator for additional pages */}
{isLoadingMore && (
<div className="flex items-center justify-center py-2">
<div className="animate-spin h-4 w-4 border-2 border-primary border-t-transparent rounded-full" />
</div>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -31,6 +31,7 @@ import { GridPattern } from "./GridPattern";
interface DocumentUploadTabProps { interface DocumentUploadTabProps {
searchSpaceId: string; searchSpaceId: string;
onSuccess?: () => void; onSuccess?: () => void;
onAccordionStateChange?: (isExpanded: boolean) => void;
} }
const audioFileTypes = { const audioFileTypes = {
@ -109,11 +110,16 @@ const FILE_TYPE_CONFIG: Record<string, Record<string, string[]>> = {
const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5"; const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5";
export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTabProps) { export function DocumentUploadTab({
searchSpaceId,
onSuccess,
onAccordionStateChange,
}: DocumentUploadTabProps) {
const t = useTranslations("upload_documents"); const t = useTranslations("upload_documents");
const router = useRouter(); const router = useRouter();
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [accordionValue, setAccordionValue] = useState<string>("");
const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom); const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom);
const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation; const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation;
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -154,6 +160,15 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa
const totalFileSize = files.reduce((total, file) => total + file.size, 0); const totalFileSize = files.reduce((total, file) => total + file.size, 0);
// Track accordion state changes
const handleAccordionChange = useCallback(
(value: string) => {
setAccordionValue(value);
onAccordionStateChange?.(value === "supported-file-types");
},
[onAccordionStateChange]
);
const handleUpload = async () => { const handleUpload = async () => {
setUploadProgress(0); setUploadProgress(0);
trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize); trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize);
@ -190,11 +205,13 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="space-y-3 sm:space-y-6 max-w-4xl mx-auto" className="space-y-3 sm:space-y-6 max-w-4xl mx-auto pt-0"
> >
<Alert className="border border-border bg-slate-400/5 dark:bg-white/5"> <Alert className="border border-border bg-slate-400/5 dark:bg-white/5 flex items-start gap-3 [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg~*]:pl-0">
<Info className="h-4 w-4" /> <Info className="h-4 w-4 shrink-0 mt-0.5" />
<AlertDescription className="text-xs sm:text-sm">{t("file_size_limit")}</AlertDescription> <AlertDescription className="text-xs sm:text-sm leading-relaxed pt-0.5">
{t("file_size_limit")}
</AlertDescription>
</Alert> </Alert>
<Card className={`relative overflow-hidden ${cardClass}`}> <Card className={`relative overflow-hidden ${cardClass}`}>
@ -366,11 +383,13 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa
<Accordion <Accordion
type="single" type="single"
collapsible collapsible
className={`w-full ${cardClass} border border-border rounded-lg`} value={accordionValue}
onValueChange={handleAccordionChange}
className={`w-full ${cardClass} border border-border rounded-lg mb-0`}
> >
<AccordionItem value="supported-file-types" className="border-0"> <AccordionItem value="supported-file-types" className="border-0">
<AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline"> <AccordionTrigger className="px-3 sm:px-6 py-3 sm:py-4 hover:no-underline !items-center [&>svg]:!translate-y-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-1">
<Tag className="h-4 w-4 sm:h-5 sm:w-5 shrink-0" /> <Tag className="h-4 w-4 sm:h-5 sm:w-5 shrink-0" />
<div className="text-left min-w-0"> <div className="text-left min-w-0">
<div className="font-semibold text-sm sm:text-base"> <div className="font-semibold text-sm sm:text-base">

View file

@ -86,7 +86,6 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
SEARXNG_LANGUAGE: "", SEARXNG_LANGUAGE: "",
SEARXNG_SAFESEARCH: "", SEARXNG_SAFESEARCH: "",
SEARXNG_VERIFY_SSL: "", SEARXNG_VERIFY_SSL: "",
LINEAR_API_KEY: "",
DISCORD_BOT_TOKEN: "", DISCORD_BOT_TOKEN: "",
CONFLUENCE_BASE_URL: "", CONFLUENCE_BASE_URL: "",
CONFLUENCE_EMAIL: "", CONFLUENCE_EMAIL: "",
@ -134,7 +133,6 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
config.SEARXNG_VERIFY_SSL !== undefined && config.SEARXNG_VERIFY_SSL !== null config.SEARXNG_VERIFY_SSL !== undefined && config.SEARXNG_VERIFY_SSL !== null
? String(config.SEARXNG_VERIFY_SSL) ? String(config.SEARXNG_VERIFY_SSL)
: "", : "",
LINEAR_API_KEY: config.LINEAR_API_KEY || "",
LINKUP_API_KEY: config.LINKUP_API_KEY || "", LINKUP_API_KEY: config.LINKUP_API_KEY || "",
DISCORD_BOT_TOKEN: config.DISCORD_BOT_TOKEN || "", DISCORD_BOT_TOKEN: config.DISCORD_BOT_TOKEN || "",
CONFLUENCE_BASE_URL: config.CONFLUENCE_BASE_URL || "", CONFLUENCE_BASE_URL: config.CONFLUENCE_BASE_URL || "",
@ -384,16 +382,6 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
break; break;
} }
case "LINEAR_CONNECTOR":
if (formData.LINEAR_API_KEY !== originalConfig.LINEAR_API_KEY) {
if (!formData.LINEAR_API_KEY) {
toast.error("Linear API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { LINEAR_API_KEY: formData.LINEAR_API_KEY };
}
break;
case "LINKUP_API": case "LINKUP_API":
if (formData.LINKUP_API_KEY !== originalConfig.LINKUP_API_KEY) { if (formData.LINKUP_API_KEY !== originalConfig.LINKUP_API_KEY) {
if (!formData.LINKUP_API_KEY) { if (!formData.LINKUP_API_KEY) {
@ -599,8 +587,6 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
"SEARXNG_VERIFY_SSL", "SEARXNG_VERIFY_SSL",
verifyValue === null ? "" : String(verifyValue) verifyValue === null ? "" : String(verifyValue)
); );
} else if (connector.connector_type === "LINEAR_CONNECTOR") {
editForm.setValue("LINEAR_API_KEY", newlySavedConfig.LINEAR_API_KEY || "");
} else if (connector.connector_type === "LINKUP_API") { } else if (connector.connector_type === "LINKUP_API") {
editForm.setValue("LINKUP_API_KEY", newlySavedConfig.LINKUP_API_KEY || ""); editForm.setValue("LINKUP_API_KEY", newlySavedConfig.LINKUP_API_KEY || "");
} else if (connector.connector_type === "DISCORD_CONNECTOR") { } else if (connector.connector_type === "DISCORD_CONNECTOR") {