mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
Merge pull request #664 from AnishSarkar22/feat/slack-oauth
feat: Slack, Discord OAuth connector & 5 min periodic syncing
This commit is contained in:
commit
afe63943f2
68 changed files with 4622 additions and 2532 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.token = token
|
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
|
||||||
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
|
||||||
"""
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
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=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:
|
try:
|
||||||
decoded_state = base64.urlsafe_b64decode(state.encode()).decode()
|
data = state_manager.validate_state(state)
|
||||||
data = json.loads(decoded_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)
|
||||||
|
|
||||||
|
|
|
||||||
509
surfsense_backend/app/routes/discord_add_connector_route.py
Normal file
509
surfsense_backend/app/routes/discord_add_connector_route.py
Normal 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
|
||||||
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,51 +22,90 @@ 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."""
|
||||||
flow = Flow.from_client_config(
|
try:
|
||||||
{
|
flow = Flow.from_client_config(
|
||||||
"web": {
|
{
|
||||||
"client_id": config.GOOGLE_OAUTH_CLIENT_ID,
|
"web": {
|
||||||
"client_secret": config.GOOGLE_OAUTH_CLIENT_SECRET,
|
"client_id": config.GOOGLE_OAUTH_CLIENT_ID,
|
||||||
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
"client_secret": config.GOOGLE_OAUTH_CLIENT_SECRET,
|
||||||
"token_uri": "https://oauth2.googleapis.com/token",
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
"redirect_uris": [config.GOOGLE_GMAIL_REDIRECT_URI],
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
}
|
"redirect_uris": [config.GOOGLE_GMAIL_REDIRECT_URI],
|
||||||
},
|
}
|
||||||
scopes=[
|
},
|
||||||
"https://www.googleapis.com/auth/gmail.readonly",
|
scopes=[
|
||||||
"https://www.googleapis.com/auth/userinfo.email",
|
"https://www.googleapis.com/auth/gmail.readonly",
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
"openid",
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
],
|
"openid",
|
||||||
)
|
],
|
||||||
flow.redirect_uri = config.GOOGLE_GMAIL_REDIRECT_URI
|
)
|
||||||
return flow
|
flow.redirect_uri = config.GOOGLE_GMAIL_REDIRECT_URI
|
||||||
|
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
|
||||||
|
|
|
||||||
448
surfsense_backend/app/routes/linear_add_connector_route.py
Normal file
448
surfsense_backend/app/routes/linear_add_connector_route.py
Normal 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
|
||||||
459
surfsense_backend/app/routes/notion_add_connector_route.py
Normal file
459
surfsense_backend/app/routes/notion_add_connector_route.py
Normal 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
|
||||||
478
surfsense_backend/app/routes/slack_add_connector_route.py
Normal file
478
surfsense_backend/app/routes/slack_add_connector_route.py
Normal 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
|
||||||
76
surfsense_backend/app/schemas/discord_auth_credentials.py
Normal file
76
surfsense_backend/app/schemas/discord_auth_credentials.py
Normal 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
|
||||||
|
|
||||||
66
surfsense_backend/app/schemas/linear_auth_credentials.py
Normal file
66
surfsense_backend/app/schemas/linear_auth_credentials.py
Normal 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
|
||||||
72
surfsense_backend/app/schemas/notion_auth_credentials.py
Normal file
72
surfsense_backend/app/schemas/notion_auth_credentials.py
Normal 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
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
75
surfsense_backend/app/schemas/slack_auth_credentials.py
Normal file
75
surfsense_backend/app/schemas/slack_auth_credentials.py
Normal 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
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
)
|
)
|
||||||
|
|
||||||
discord_client = DiscordConnector(token=discord_token)
|
# 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)
|
||||||
|
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 = (
|
||||||
|
datetime.strptime(start_date, "%Y-%m-%d")
|
||||||
|
.replace(tzinfo=UTC)
|
||||||
|
.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:
|
||||||
|
end_date_iso = calculated_end_date.isoformat()
|
||||||
|
else:
|
||||||
|
# Validate and convert YYYY-MM-DD to ISO format
|
||||||
|
try:
|
||||||
|
end_date_iso = (
|
||||||
|
datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
.replace(tzinfo=UTC)
|
||||||
|
.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:
|
||||||
|
# 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:
|
||||||
if end_date is None:
|
await task_logger.log_task_failure(
|
||||||
end_date_iso = calculated_end_date.isoformat()
|
log_entry,
|
||||||
else:
|
f"Invalid start_date format: {start_date}",
|
||||||
# Convert YYYY-MM-DD to ISO format
|
f"Date parsing error: {e!s}",
|
||||||
end_date_iso = (
|
{"error_type": "InvalidDateFormat", "start_date": start_date},
|
||||||
datetime.strptime(end_date, "%Y-%m-%d")
|
|
||||||
.replace(tzinfo=UTC)
|
|
||||||
.isoformat()
|
|
||||||
)
|
)
|
||||||
else:
|
return 0, f"Invalid start_date format: {start_date}. Expected YYYY-MM-DD format."
|
||||||
# Convert provided dates to ISO format for Discord API
|
|
||||||
start_date_iso = (
|
try:
|
||||||
datetime.strptime(start_date, "%Y-%m-%d")
|
end_date_iso = (
|
||||||
.replace(tzinfo=UTC)
|
datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC).isoformat()
|
||||||
.isoformat()
|
)
|
||||||
)
|
except ValueError as e:
|
||||||
end_date_iso = (
|
await task_logger.log_task_failure(
|
||||||
datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC).isoformat()
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
|
||||||
"%Y-%m-%dT%H:%M:%SZ"
|
|
||||||
)
|
|
||||||
|
|
||||||
if end_date is None:
|
# Convert YYYY-MM-DD to ISO format for Notion API
|
||||||
end_date_iso = calculated_end_date.strftime("%Y-%m-%dT%H:%M:%SZ")
|
start_date_iso = datetime.strptime(start_date_str, "%Y-%m-%d").strftime(
|
||||||
else:
|
"%Y-%m-%dT%H:%M:%SZ"
|
||||||
# Convert YYYY-MM-DD to ISO format
|
)
|
||||||
end_date_iso = datetime.strptime(end_date, "%Y-%m-%d").strftime(
|
end_date_iso = datetime.strptime(end_date_str, "%Y-%m-%d").strftime(
|
||||||
"%Y-%m-%dT%H:%M:%SZ"
|
"%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)
|
# Create connector with session and connector_id for internal refresh
|
||||||
|
# Token refresh will happen automatically when needed
|
||||||
|
notion_client = NotionHistoryConnector(
|
||||||
|
session=session, connector_id=connector_id
|
||||||
|
)
|
||||||
|
|
||||||
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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
210
surfsense_backend/app/utils/oauth_security.py
Normal file
210
surfsense_backend/app/utils/oauth_security.py
Normal 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
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" ? (
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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...
|
||||||
|
|
|
||||||
|
|
@ -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,15 +98,17 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
isScrolled && "shadow-sm"
|
isScrolled && "shadow-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Back button */}
|
{/* Back button - only show if not from OAuth */}
|
||||||
<button
|
{!isFromOAuth && (
|
||||||
type="button"
|
<button
|
||||||
onClick={onSkip}
|
type="button"
|
||||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
|
onClick={onSkip}
|
||||||
>
|
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
|
||||||
<ArrowLeft className="size-4" />
|
>
|
||||||
Back to connectors
|
<ArrowLeft className="size-4" />
|
||||||
</button>
|
Back to connectors
|
||||||
|
</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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
|
||||||
<div className="h-full overflow-y-auto">
|
{/* Fixed Header */}
|
||||||
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
|
<div className="flex-shrink-0 px-4 sm:px-12 pt-6 sm:pt-10 transition-shadow duration-200 relative z-10">
|
||||||
<DocumentUploadTab searchSpaceId={searchSpaceId} onSuccess={handleSuccess} />
|
{/* 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>
|
</div>
|
||||||
{/* Bottom fade shadow */}
|
</div>
|
||||||
<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" />
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||||||
|
<div className={`h-full ${isAccordionExpanded ? "overflow-y-auto" : ""}`}>
|
||||||
|
<div className="px-6 sm:px-12 pb-5 sm:pb-16">
|
||||||
|
<DocumentUploadTab
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
onAccordionStateChange={setIsAccordionExpanded}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue