mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
feat: implement Jira OAuth integration and connector routes
- Added support for Jira OAuth with new environment variables for client ID, client secret, and redirect URI. - Implemented Jira connector routes for OAuth flow, including authorization and callback handling. - Enhanced Jira connector to support both OAuth 2.0 and legacy API token authentication methods. - Updated Jira indexing logic to utilize OAuth credentials with auto-refresh capabilities. - Removed outdated Jira UI components and adjusted frontend logic to reflect the new integration.
This commit is contained in:
parent
df23813f1c
commit
982b9ceb76
13 changed files with 855 additions and 529 deletions
|
|
@ -50,6 +50,11 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret_here
|
||||||
DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback
|
DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback
|
||||||
DISCORD_BOT_TOKEN=your_bot_token_from_developer_portal
|
DISCORD_BOT_TOKEN=your_bot_token_from_developer_portal
|
||||||
|
|
||||||
|
# Jira OAuth Configuration
|
||||||
|
JIRA_CLIENT_ID=our_jira_client_id
|
||||||
|
JIRA_CLIENT_SECRET=your_jira_client_secret
|
||||||
|
JIRA_REDIRECT_URI=http://localhost:8000/api/v1/auth/jira/connector/callback
|
||||||
|
|
||||||
# OAuth for Linear Connector
|
# OAuth for Linear Connector
|
||||||
LINEAR_CLIENT_ID=your_linear_client_id
|
LINEAR_CLIENT_ID=your_linear_client_id
|
||||||
LINEAR_CLIENT_SECRET=your_linear_client_secret
|
LINEAR_CLIENT_SECRET=your_linear_client_secret
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ Jira Connector Module
|
||||||
|
|
||||||
A module for retrieving data from Jira.
|
A module for retrieving data from Jira.
|
||||||
Allows fetching issue lists and their comments, projects and more.
|
Allows fetching issue lists and their comments, projects and more.
|
||||||
|
Supports both OAuth 2.0 (preferred) and legacy API token authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
@ -18,6 +19,8 @@ class JiraConnector:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
base_url: str | None = None,
|
base_url: str | None = None,
|
||||||
|
access_token: str | None = None,
|
||||||
|
cloud_id: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
api_token: str | None = None,
|
api_token: str | None = None,
|
||||||
):
|
):
|
||||||
|
|
@ -25,18 +28,39 @@ class JiraConnector:
|
||||||
Initialize the JiraConnector class.
|
Initialize the JiraConnector class.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') (optional)
|
base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net')
|
||||||
email: Jira account email address (optional)
|
access_token: OAuth 2.0 access token (preferred method)
|
||||||
api_token: Jira API token (optional)
|
cloud_id: Atlassian cloud ID (used with OAuth for API URL construction)
|
||||||
|
email: Jira account email address (legacy method, used with api_token)
|
||||||
|
api_token: Jira API token (legacy method, used with email)
|
||||||
"""
|
"""
|
||||||
self.base_url = base_url.rstrip("/") if base_url else None
|
self.base_url = base_url.rstrip("/") if base_url else None
|
||||||
|
self.access_token = access_token
|
||||||
|
self.cloud_id = cloud_id
|
||||||
self.email = email
|
self.email = email
|
||||||
self.api_token = api_token
|
self.api_token = api_token
|
||||||
self.api_version = "3" # Jira Cloud API version
|
self.api_version = "3" # Jira Cloud API version
|
||||||
|
self._use_oauth = access_token is not None
|
||||||
|
|
||||||
|
def set_oauth_credentials(
|
||||||
|
self, base_url: str, access_token: str, cloud_id: str | None = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Set OAuth 2.0 credentials (preferred method).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Jira instance base URL
|
||||||
|
access_token: OAuth 2.0 access token
|
||||||
|
cloud_id: Atlassian cloud ID (optional, used for API URL construction)
|
||||||
|
"""
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.access_token = access_token
|
||||||
|
self.cloud_id = cloud_id
|
||||||
|
self._use_oauth = True
|
||||||
|
|
||||||
def set_credentials(self, base_url: str, email: str, api_token: str) -> None:
|
def set_credentials(self, base_url: str, email: str, api_token: str) -> None:
|
||||||
"""
|
"""
|
||||||
Set the Jira credentials.
|
Set the Jira credentials (legacy method using API token).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
base_url: Jira instance base URL
|
base_url: Jira instance base URL
|
||||||
|
|
@ -46,50 +70,69 @@ class JiraConnector:
|
||||||
self.base_url = base_url.rstrip("/")
|
self.base_url = base_url.rstrip("/")
|
||||||
self.email = email
|
self.email = email
|
||||||
self.api_token = api_token
|
self.api_token = api_token
|
||||||
|
self._use_oauth = False
|
||||||
|
|
||||||
def set_email(self, email: str) -> None:
|
def set_email(self, email: str) -> None:
|
||||||
"""
|
"""
|
||||||
Set the Jira account email.
|
Set the Jira account email (legacy method).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
email: Jira account email address
|
email: Jira account email address
|
||||||
"""
|
"""
|
||||||
self.email = email
|
self.email = email
|
||||||
|
self._use_oauth = False
|
||||||
|
|
||||||
def set_api_token(self, api_token: str) -> None:
|
def set_api_token(self, api_token: str) -> None:
|
||||||
"""
|
"""
|
||||||
Set the Jira API token.
|
Set the Jira API token (legacy method).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_token: Jira API token
|
api_token: Jira API token
|
||||||
"""
|
"""
|
||||||
self.api_token = api_token
|
self.api_token = api_token
|
||||||
|
self._use_oauth = False
|
||||||
|
|
||||||
def get_headers(self) -> dict[str, str]:
|
def get_headers(self) -> dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Get headers for Jira API requests using Basic Authentication.
|
Get headers for Jira API requests.
|
||||||
|
|
||||||
|
Uses OAuth Bearer token if available, otherwise falls back to Basic Auth.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary of headers
|
Dictionary of headers
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If email, api_token, or base_url have not been set
|
ValueError: If credentials have not been set
|
||||||
"""
|
"""
|
||||||
if not all([self.base_url, self.email, self.api_token]):
|
if self._use_oauth:
|
||||||
raise ValueError(
|
# OAuth 2.0 authentication
|
||||||
"Jira credentials not initialized. Call set_credentials() first."
|
if not self.base_url or not self.access_token:
|
||||||
)
|
raise ValueError(
|
||||||
|
"Jira OAuth credentials not initialized. Call set_oauth_credentials() first."
|
||||||
|
)
|
||||||
|
|
||||||
# Create Basic Auth header using email:api_token
|
return {
|
||||||
auth_str = f"{self.email}:{self.api_token}"
|
"Content-Type": "application/json",
|
||||||
auth_bytes = auth_str.encode("utf-8")
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii")
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Legacy Basic Auth
|
||||||
|
if not all([self.base_url, self.email, self.api_token]):
|
||||||
|
raise ValueError(
|
||||||
|
"Jira credentials not initialized. Call set_credentials() first."
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
# Create Basic Auth header using email:api_token
|
||||||
"Content-Type": "application/json",
|
auth_str = f"{self.email}:{self.api_token}"
|
||||||
"Authorization": auth_header,
|
auth_bytes = auth_str.encode("utf-8")
|
||||||
"Accept": "application/json",
|
auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii")
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": auth_header,
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
def make_api_request(
|
def make_api_request(
|
||||||
self,
|
self,
|
||||||
|
|
@ -104,22 +147,26 @@ class JiraConnector:
|
||||||
Args:
|
Args:
|
||||||
endpoint: API endpoint (without base URL)
|
endpoint: API endpoint (without base URL)
|
||||||
params: Query parameters for the request (optional)
|
params: Query parameters for the request (optional)
|
||||||
|
method: HTTP method (GET or POST)
|
||||||
|
json_payload: JSON payload for POST requests (optional)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response data from the API
|
Response data from the API
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If email, api_token, or base_url have not been set
|
ValueError: If credentials have not been set
|
||||||
Exception: If the API request fails
|
Exception: If the API request fails
|
||||||
"""
|
"""
|
||||||
if not all([self.base_url, self.email, self.api_token]):
|
|
||||||
raise ValueError(
|
|
||||||
"Jira credentials not initialized. Call set_credentials() first."
|
|
||||||
)
|
|
||||||
|
|
||||||
url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}"
|
|
||||||
headers = self.get_headers()
|
headers = self.get_headers()
|
||||||
|
|
||||||
|
# Construct API URL based on authentication method
|
||||||
|
if self._use_oauth and self.cloud_id:
|
||||||
|
# Use Atlassian API gateway with cloud_id for OAuth
|
||||||
|
url = f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/{self.api_version}/{endpoint}"
|
||||||
|
else:
|
||||||
|
# Use direct base URL (works for both OAuth and legacy)
|
||||||
|
url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}"
|
||||||
|
|
||||||
if method.upper() == "POST":
|
if method.upper() == "POST":
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
url, headers=headers, json=json_payload, timeout=500
|
url, headers=headers, json=json_payload, timeout=500
|
||||||
|
|
@ -234,15 +281,24 @@ class JiraConnector:
|
||||||
try:
|
try:
|
||||||
# Build JQL query for date range
|
# Build JQL query for date range
|
||||||
# Query issues that were either created OR updated within the date range
|
# Query issues that were either created OR updated within the date range
|
||||||
|
# Use end_date + 1 day with < operator to include the full end date
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Parse end_date and add 1 day for inclusive end date
|
||||||
|
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
|
||||||
|
end_date_next = (end_date_obj + timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Check both created and updated dates to catch all relevant issues
|
||||||
|
# Use 'created' and 'updated' (standard JQL field names)
|
||||||
date_filter = (
|
date_filter = (
|
||||||
f"(createdDate >= '{start_date}' AND createdDate <= '{end_date}')"
|
f"(created >= '{start_date}' AND created < '{end_date_next}') "
|
||||||
|
f"OR (updated >= '{start_date}' AND updated < '{end_date_next}')"
|
||||||
)
|
)
|
||||||
# TODO : This JQL needs some improvement to work as expected
|
|
||||||
|
|
||||||
jql = f"{date_filter}"
|
jql = f"{date_filter} ORDER BY created DESC"
|
||||||
if project_key:
|
if project_key:
|
||||||
jql = (
|
jql = (
|
||||||
f'project = "{project_key}" AND {date_filter} ORDER BY created DESC'
|
f'project = "{project_key}" AND ({date_filter}) ORDER BY created DESC'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Define fields to retrieve
|
# Define fields to retrieve
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from .airtable_add_connector_route import (
|
||||||
router as airtable_add_connector_router,
|
router as airtable_add_connector_router,
|
||||||
)
|
)
|
||||||
from .circleback_webhook_route import router as circleback_webhook_router
|
from .circleback_webhook_route import router as circleback_webhook_router
|
||||||
|
from .discord_add_connector_route import router as discord_add_connector_router
|
||||||
from .documents_routes import router as documents_router
|
from .documents_routes import router as documents_router
|
||||||
from .editor_routes import router as editor_router
|
from .editor_routes import router as editor_router
|
||||||
from .google_calendar_add_connector_route import (
|
from .google_calendar_add_connector_route import (
|
||||||
|
|
@ -15,6 +16,7 @@ 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 .jira_add_connector_route import router as jira_add_connector_router
|
||||||
from .linear_add_connector_route import router as linear_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
|
||||||
|
|
@ -27,7 +29,6 @@ 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 .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()
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ router.include_router(luma_add_connector_router)
|
||||||
router.include_router(notion_add_connector_router)
|
router.include_router(notion_add_connector_router)
|
||||||
router.include_router(slack_add_connector_router)
|
router.include_router(slack_add_connector_router)
|
||||||
router.include_router(discord_add_connector_router)
|
router.include_router(discord_add_connector_router)
|
||||||
|
router.include_router(jira_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
|
||||||
|
|
|
||||||
494
surfsense_backend/app/routes/jira_add_connector_route.py
Normal file
494
surfsense_backend/app/routes/jira_add_connector_route.py
Normal file
|
|
@ -0,0 +1,494 @@
|
||||||
|
"""
|
||||||
|
Jira Connector OAuth Routes.
|
||||||
|
|
||||||
|
Handles OAuth 2.0 authentication flow for Jira connector.
|
||||||
|
Uses Atlassian OAuth 2.0 (3LO) with accessible-resources API to discover Jira instances.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.db import (
|
||||||
|
SearchSourceConnector,
|
||||||
|
SearchSourceConnectorType,
|
||||||
|
User,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
|
from app.schemas.jira_auth_credentials import JiraAuthCredentialsBase
|
||||||
|
from app.users import current_active_user
|
||||||
|
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Atlassian OAuth endpoints
|
||||||
|
AUTHORIZATION_URL = "https://auth.atlassian.com/authorize"
|
||||||
|
TOKEN_URL = "https://auth.atlassian.com/oauth/token"
|
||||||
|
ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"
|
||||||
|
|
||||||
|
# OAuth scopes for Jira
|
||||||
|
SCOPES = [
|
||||||
|
"read:jira-work",
|
||||||
|
"write:jira-work",
|
||||||
|
"read:jira-user",
|
||||||
|
"offline_access", # Required for refresh tokens
|
||||||
|
]
|
||||||
|
|
||||||
|
# Initialize security utilities
|
||||||
|
_state_manager = None
|
||||||
|
_token_encryption = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_state_manager() -> OAuthStateManager:
|
||||||
|
"""Get or create OAuth state manager instance."""
|
||||||
|
global _state_manager
|
||||||
|
if _state_manager is None:
|
||||||
|
if not config.SECRET_KEY:
|
||||||
|
raise ValueError("SECRET_KEY must be set for OAuth security")
|
||||||
|
_state_manager = OAuthStateManager(config.SECRET_KEY)
|
||||||
|
return _state_manager
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_encryption() -> TokenEncryption:
|
||||||
|
"""Get or create token encryption instance."""
|
||||||
|
global _token_encryption
|
||||||
|
if _token_encryption is None:
|
||||||
|
if not config.SECRET_KEY:
|
||||||
|
raise ValueError("SECRET_KEY must be set for token encryption")
|
||||||
|
_token_encryption = TokenEncryption(config.SECRET_KEY)
|
||||||
|
return _token_encryption
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/jira/connector/add")
|
||||||
|
async def connect_jira(space_id: int, user: User = Depends(current_active_user)):
|
||||||
|
"""
|
||||||
|
Initiate Jira OAuth flow.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
space_id: The search space ID
|
||||||
|
user: Current authenticated user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authorization URL for redirect
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not space_id:
|
||||||
|
raise HTTPException(status_code=400, detail="space_id is required")
|
||||||
|
|
||||||
|
if not config.JIRA_CLIENT_ID:
|
||||||
|
raise HTTPException(status_code=500, detail="Jira OAuth not configured.")
|
||||||
|
|
||||||
|
if not config.SECRET_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="SECRET_KEY not configured for OAuth security."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate secure state parameter with HMAC signature
|
||||||
|
state_manager = get_state_manager()
|
||||||
|
state_encoded = state_manager.generate_secure_state(space_id, user.id)
|
||||||
|
|
||||||
|
# Build authorization URL
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
auth_params = {
|
||||||
|
"audience": "api.atlassian.com",
|
||||||
|
"client_id": config.JIRA_CLIENT_ID,
|
||||||
|
"scope": " ".join(SCOPES),
|
||||||
|
"redirect_uri": config.JIRA_REDIRECT_URI,
|
||||||
|
"state": state_encoded,
|
||||||
|
"response_type": "code",
|
||||||
|
"prompt": "consent", # Force consent screen to get refresh token
|
||||||
|
}
|
||||||
|
|
||||||
|
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
|
||||||
|
|
||||||
|
logger.info(f"Generated Jira OAuth URL for user {user.id}, space {space_id}")
|
||||||
|
return {"auth_url": auth_url}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initiate Jira OAuth: {e!s}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to initiate Jira OAuth: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/jira/connector/callback")
|
||||||
|
async def jira_callback(
|
||||||
|
request: Request,
|
||||||
|
code: str | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
state: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle Jira OAuth callback.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request object
|
||||||
|
code: Authorization code from Atlassian (if user granted access)
|
||||||
|
error: Error code from Atlassian (if user denied access or error occurred)
|
||||||
|
state: State parameter containing user/space info
|
||||||
|
session: Database session
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect response to frontend
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Handle OAuth errors (e.g., user denied access)
|
||||||
|
if error:
|
||||||
|
logger.warning(f"Jira OAuth error: {error}")
|
||||||
|
# Try to decode state to get space_id for redirect, but don't fail if it's invalid
|
||||||
|
space_id = None
|
||||||
|
if state:
|
||||||
|
try:
|
||||||
|
state_manager = get_state_manager()
|
||||||
|
data = state_manager.validate_state(state)
|
||||||
|
space_id = data.get("space_id")
|
||||||
|
except Exception:
|
||||||
|
# If state is invalid, we'll redirect without space_id
|
||||||
|
logger.warning("Failed to validate state in error handler")
|
||||||
|
|
||||||
|
# Redirect to frontend with error parameter
|
||||||
|
if space_id:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=jira_oauth_denied"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=jira_oauth_denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate required parameters for successful flow
|
||||||
|
if not code:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing authorization code")
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing state parameter")
|
||||||
|
|
||||||
|
# Validate and decode state with signature verification
|
||||||
|
state_manager = get_state_manager()
|
||||||
|
try:
|
||||||
|
data = state_manager.validate_state(state)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid state parameter: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
user_id = UUID(data["user_id"])
|
||||||
|
space_id = data["space_id"]
|
||||||
|
|
||||||
|
# Validate redirect URI (security: ensure it matches configured value)
|
||||||
|
if not config.JIRA_REDIRECT_URI:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="JIRA_REDIRECT_URI not configured"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exchange authorization code for access token
|
||||||
|
token_data = {
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": config.JIRA_CLIENT_ID,
|
||||||
|
"client_secret": config.JIRA_CLIENT_SECRET,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": config.JIRA_REDIRECT_URI,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
token_response = await client.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
data=token_data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_response.status_code != 200:
|
||||||
|
error_detail = token_response.text
|
||||||
|
try:
|
||||||
|
error_json = token_response.json()
|
||||||
|
error_detail = error_json.get("error_description", error_json.get("error", error_detail))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Token exchange failed: {error_detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
token_json = token_response.json()
|
||||||
|
|
||||||
|
# Encrypt sensitive tokens before storing
|
||||||
|
token_encryption = get_token_encryption()
|
||||||
|
access_token = token_json.get("access_token")
|
||||||
|
refresh_token = token_json.get("refresh_token")
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="No access token received from Atlassian"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch accessible resources to get Jira instance information
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resources_response = await client.get(
|
||||||
|
ACCESSIBLE_RESOURCES_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resources_response.status_code != 200:
|
||||||
|
error_detail = resources_response.text
|
||||||
|
logger.error(f"Failed to fetch accessible resources: {error_detail}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Failed to fetch Jira instances: {error_detail}",
|
||||||
|
)
|
||||||
|
|
||||||
|
resources = resources_response.json()
|
||||||
|
|
||||||
|
# Filter for Jira instances (resources with type "jira" or id field)
|
||||||
|
jira_instances = [
|
||||||
|
r
|
||||||
|
for r in resources
|
||||||
|
if r.get("id") and (r.get("name") or r.get("url"))
|
||||||
|
]
|
||||||
|
|
||||||
|
if not jira_instances:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No accessible Jira instances found. Please ensure you have access to at least one Jira instance.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# For now, use the first Jira instance
|
||||||
|
# TODO: Support multiple instances by letting user choose during OAuth
|
||||||
|
jira_instance = jira_instances[0]
|
||||||
|
cloud_id = jira_instance["id"]
|
||||||
|
base_url = jira_instance.get("url")
|
||||||
|
|
||||||
|
# If URL is not provided, construct it from cloud_id
|
||||||
|
if not base_url:
|
||||||
|
# Try to extract from name or construct default format
|
||||||
|
instance_name = jira_instance.get("name", "").lower().replace(" ", "")
|
||||||
|
if instance_name:
|
||||||
|
base_url = f"https://{instance_name}.atlassian.net"
|
||||||
|
else:
|
||||||
|
# Fallback: use cloud_id directly (though this may not work)
|
||||||
|
base_url = f"https://{cloud_id}.atlassian.net"
|
||||||
|
|
||||||
|
# Calculate expiration time (UTC, tz-aware)
|
||||||
|
expires_at = None
|
||||||
|
expires_in = token_json.get("expires_in")
|
||||||
|
if expires_in:
|
||||||
|
now_utc = datetime.now(UTC)
|
||||||
|
expires_at = now_utc + timedelta(seconds=int(expires_in))
|
||||||
|
|
||||||
|
# Store the encrypted access token and refresh token in connector config
|
||||||
|
connector_config = {
|
||||||
|
"access_token": token_encryption.encrypt_token(access_token),
|
||||||
|
"refresh_token": token_encryption.encrypt_token(refresh_token)
|
||||||
|
if refresh_token
|
||||||
|
else None,
|
||||||
|
"token_type": token_json.get("token_type", "Bearer"),
|
||||||
|
"expires_in": expires_in,
|
||||||
|
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||||
|
"scope": token_json.get("scope"),
|
||||||
|
"cloud_id": cloud_id,
|
||||||
|
"base_url": base_url.rstrip("/") if base_url else None,
|
||||||
|
# Mark that tokens are encrypted for backward compatibility
|
||||||
|
"_token_encrypted": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if connector already exists for this search space and user
|
||||||
|
existing_connector_result = await session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.search_space_id == space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
SearchSourceConnector.connector_type
|
||||||
|
== SearchSourceConnectorType.JIRA_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_connector = existing_connector_result.scalars().first()
|
||||||
|
|
||||||
|
if existing_connector:
|
||||||
|
# Update existing connector
|
||||||
|
existing_connector.config = connector_config
|
||||||
|
existing_connector.name = "Jira Connector"
|
||||||
|
existing_connector.is_indexable = True
|
||||||
|
logger.info(
|
||||||
|
f"Updated existing Jira connector for user {user_id} in space {space_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Create new connector
|
||||||
|
new_connector = SearchSourceConnector(
|
||||||
|
name="Jira Connector",
|
||||||
|
connector_type=SearchSourceConnectorType.JIRA_CONNECTOR,
|
||||||
|
is_indexable=True,
|
||||||
|
config=connector_config,
|
||||||
|
search_space_id=space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
session.add(new_connector)
|
||||||
|
logger.info(
|
||||||
|
f"Created new Jira connector for user {user_id} in space {space_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Successfully saved Jira connector for user {user_id}")
|
||||||
|
|
||||||
|
# Redirect to the frontend with success params
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=jira-connector"
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail=f"Validation error: {e!s}"
|
||||||
|
) from e
|
||||||
|
except IntegrityError as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Integrity error: A connector with this type already exists. {e!s}",
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create search source connector: {e!s}")
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to create search source connector: {e!s}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to complete Jira OAuth: {e!s}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to complete Jira OAuth: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
async def refresh_jira_token(
|
||||||
|
session: AsyncSession, connector: SearchSourceConnector
|
||||||
|
) -> SearchSourceConnector:
|
||||||
|
"""
|
||||||
|
Refresh the Jira access token for a connector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
connector: Jira connector to refresh
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated connector object
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Refreshing Jira token for connector {connector.id}")
|
||||||
|
|
||||||
|
credentials = JiraAuthCredentialsBase.from_dict(connector.config)
|
||||||
|
|
||||||
|
# Decrypt tokens if they are encrypted
|
||||||
|
token_encryption = get_token_encryption()
|
||||||
|
is_encrypted = connector.config.get("_token_encrypted", False)
|
||||||
|
|
||||||
|
refresh_token = credentials.refresh_token
|
||||||
|
if is_encrypted and refresh_token:
|
||||||
|
try:
|
||||||
|
refresh_token = token_encryption.decrypt_token(refresh_token)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to decrypt refresh token: {e!s}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to decrypt stored refresh token"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if not refresh_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="No refresh token available. Please re-authenticate.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare token refresh data
|
||||||
|
refresh_data = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"client_id": config.JIRA_CLIENT_ID,
|
||||||
|
"client_secret": config.JIRA_CLIENT_SECRET,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
token_response = await client.post(
|
||||||
|
TOKEN_URL,
|
||||||
|
data=refresh_data,
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if token_response.status_code != 200:
|
||||||
|
error_detail = token_response.text
|
||||||
|
try:
|
||||||
|
error_json = token_response.json()
|
||||||
|
error_detail = error_json.get("error_description", error_json.get("error", error_detail))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Token refresh failed: {error_detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
token_json = token_response.json()
|
||||||
|
|
||||||
|
# Calculate expiration time (UTC, tz-aware)
|
||||||
|
expires_at = None
|
||||||
|
expires_in = token_json.get("expires_in")
|
||||||
|
if expires_in:
|
||||||
|
now_utc = datetime.now(UTC)
|
||||||
|
expires_at = now_utc + timedelta(seconds=int(expires_in))
|
||||||
|
|
||||||
|
# Encrypt new tokens before storing
|
||||||
|
access_token = token_json.get("access_token")
|
||||||
|
new_refresh_token = token_json.get("refresh_token")
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="No access token received from Jira refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update credentials object with encrypted tokens
|
||||||
|
credentials.access_token = token_encryption.encrypt_token(access_token)
|
||||||
|
if new_refresh_token:
|
||||||
|
credentials.refresh_token = token_encryption.encrypt_token(
|
||||||
|
new_refresh_token
|
||||||
|
)
|
||||||
|
credentials.expires_in = expires_in
|
||||||
|
credentials.expires_at = expires_at
|
||||||
|
credentials.scope = token_json.get("scope")
|
||||||
|
|
||||||
|
# Preserve cloud_id and base_url
|
||||||
|
if not credentials.cloud_id:
|
||||||
|
credentials.cloud_id = connector.config.get("cloud_id")
|
||||||
|
if not credentials.base_url:
|
||||||
|
credentials.base_url = connector.config.get("base_url")
|
||||||
|
|
||||||
|
# Update connector config with encrypted tokens
|
||||||
|
credentials_dict = credentials.to_dict()
|
||||||
|
credentials_dict["_token_encrypted"] = True
|
||||||
|
connector.config = credentials_dict
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(connector)
|
||||||
|
|
||||||
|
logger.info(f"Successfully refreshed Jira token for connector {connector.id}")
|
||||||
|
|
||||||
|
return connector
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to refresh Jira token: {e!s}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to refresh Jira token: {e!s}"
|
||||||
|
) from e
|
||||||
72
surfsense_backend/app/schemas/jira_auth_credentials.py
Normal file
72
surfsense_backend/app/schemas/jira_auth_credentials.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class JiraAuthCredentialsBase(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
|
||||||
|
cloud_id: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""Check if the credentials have expired."""
|
||||||
|
if self.expires_at is None:
|
||||||
|
return False
|
||||||
|
return self.expires_at <= datetime.now(UTC)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_refreshable(self) -> bool:
|
||||||
|
"""Check if the credentials can be refreshed."""
|
||||||
|
return self.refresh_token is not None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert credentials to dictionary for storage."""
|
||||||
|
return {
|
||||||
|
"access_token": self.access_token,
|
||||||
|
"refresh_token": self.refresh_token,
|
||||||
|
"token_type": self.token_type,
|
||||||
|
"expires_in": self.expires_in,
|
||||||
|
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||||
|
"scope": self.scope,
|
||||||
|
"cloud_id": self.cloud_id,
|
||||||
|
"base_url": self.base_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "JiraAuthCredentialsBase":
|
||||||
|
"""Create credentials from dictionary."""
|
||||||
|
expires_at = None
|
||||||
|
if data.get("expires_at"):
|
||||||
|
expires_at = datetime.fromisoformat(data["expires_at"])
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
access_token=data["access_token"],
|
||||||
|
refresh_token=data.get("refresh_token"),
|
||||||
|
token_type=data.get("token_type", "Bearer"),
|
||||||
|
expires_in=data.get("expires_in"),
|
||||||
|
expires_at=expires_at,
|
||||||
|
scope=data.get("scope"),
|
||||||
|
cloud_id=data.get("cloud_id"),
|
||||||
|
base_url=data.get("base_url"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("expires_at", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def ensure_aware_utc(cls, v):
|
||||||
|
# Strings like "2025-08-26T14:46:57.367184"
|
||||||
|
if isinstance(v, str):
|
||||||
|
# add +00:00 if missing tz info
|
||||||
|
if v.endswith("Z"):
|
||||||
|
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||||
|
dt = datetime.fromisoformat(v)
|
||||||
|
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
||||||
|
# datetime objects
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v if v.tzinfo else v.replace(tzinfo=UTC)
|
||||||
|
return v
|
||||||
|
|
@ -84,31 +84,137 @@ async def index_jira_issues(
|
||||||
return 0, f"Connector with ID {connector_id} not found"
|
return 0, f"Connector with ID {connector_id} not found"
|
||||||
|
|
||||||
# Get the Jira credentials from the connector config
|
# Get the Jira credentials from the connector config
|
||||||
jira_email = connector.config.get("JIRA_EMAIL")
|
# Support both OAuth (preferred) and legacy API token authentication
|
||||||
jira_api_token = connector.config.get("JIRA_API_TOKEN")
|
config_data = connector.config.copy()
|
||||||
jira_base_url = connector.config.get("JIRA_BASE_URL")
|
is_oauth = config_data.get("_token_encrypted", False) or config_data.get("access_token")
|
||||||
|
|
||||||
if not jira_email or not jira_api_token or not jira_base_url:
|
if is_oauth:
|
||||||
await task_logger.log_task_failure(
|
# OAuth 2.0 authentication
|
||||||
|
from app.utils.oauth_security import TokenEncryption
|
||||||
|
|
||||||
|
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 Jira 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 Jira refresh token for connector {connector_id}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry,
|
||||||
|
f"Failed to decrypt Jira tokens for connector {connector_id}: {e!s}",
|
||||||
|
"Token decryption failed",
|
||||||
|
{"error_type": "TokenDecryptionError"},
|
||||||
|
)
|
||||||
|
return 0, f"Failed to decrypt Jira tokens: {e!s}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.schemas.jira_auth_credentials import JiraAuthCredentialsBase
|
||||||
|
credentials = JiraAuthCredentialsBase.from_dict(config_data)
|
||||||
|
except Exception as e:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry,
|
||||||
|
f"Invalid Jira OAuth credentials in connector {connector_id}",
|
||||||
|
str(e),
|
||||||
|
{"error_type": "InvalidCredentials"},
|
||||||
|
)
|
||||||
|
return 0, f"Invalid Jira OAuth credentials: {e!s}"
|
||||||
|
|
||||||
|
# Check if credentials are expired and refresh if needed
|
||||||
|
if credentials.is_expired:
|
||||||
|
await task_logger.log_task_progress(
|
||||||
|
log_entry,
|
||||||
|
f"Jira credentials expired for connector {connector_id}, refreshing token",
|
||||||
|
{"stage": "token_refresh"},
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.routes.jira_add_connector_route import refresh_jira_token
|
||||||
|
|
||||||
|
try:
|
||||||
|
connector = await refresh_jira_token(session, connector)
|
||||||
|
# Re-fetch credentials after refresh
|
||||||
|
config_data = connector.config.copy()
|
||||||
|
if config_data.get("access_token"):
|
||||||
|
config_data["access_token"] = token_encryption.decrypt_token(
|
||||||
|
config_data["access_token"]
|
||||||
|
)
|
||||||
|
credentials = JiraAuthCredentialsBase.from_dict(config_data)
|
||||||
|
except Exception as e:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry,
|
||||||
|
f"Failed to refresh Jira token for connector {connector_id}: {e!s}",
|
||||||
|
"Token refresh failed",
|
||||||
|
{"error_type": "TokenRefreshError"},
|
||||||
|
)
|
||||||
|
return 0, f"Failed to refresh Jira token: {e!s}"
|
||||||
|
|
||||||
|
# Initialize Jira client with OAuth credentials
|
||||||
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Jira credentials not found in connector config for connector {connector_id}",
|
f"Initializing Jira client with OAuth for connector {connector_id}",
|
||||||
"Missing Jira credentials",
|
{"stage": "client_initialization"},
|
||||||
{"error_type": "MissingCredentials"},
|
|
||||||
)
|
)
|
||||||
return 0, "Jira credentials not found in connector config"
|
|
||||||
|
|
||||||
# Initialize Jira client
|
jira_client = JiraConnector(
|
||||||
await task_logger.log_task_progress(
|
base_url=credentials.base_url,
|
||||||
log_entry,
|
access_token=credentials.access_token,
|
||||||
f"Initializing Jira client for connector {connector_id}",
|
cloud_id=credentials.cloud_id,
|
||||||
{"stage": "client_initialization"},
|
)
|
||||||
)
|
else:
|
||||||
|
# Legacy API token authentication
|
||||||
|
jira_email = config_data.get("JIRA_EMAIL")
|
||||||
|
jira_api_token = config_data.get("JIRA_API_TOKEN")
|
||||||
|
jira_base_url = config_data.get("JIRA_BASE_URL")
|
||||||
|
|
||||||
jira_client = JiraConnector(
|
if not jira_email or not jira_api_token or not jira_base_url:
|
||||||
base_url=jira_base_url, email=jira_email, api_token=jira_api_token
|
await task_logger.log_task_failure(
|
||||||
)
|
log_entry,
|
||||||
|
f"Jira credentials not found in connector config for connector {connector_id}",
|
||||||
|
"Missing Jira credentials",
|
||||||
|
{"error_type": "MissingCredentials"},
|
||||||
|
)
|
||||||
|
return 0, "Jira credentials not found in connector config"
|
||||||
|
|
||||||
|
# Initialize Jira client with legacy credentials
|
||||||
|
await task_logger.log_task_progress(
|
||||||
|
log_entry,
|
||||||
|
f"Initializing Jira client with API token for connector {connector_id}",
|
||||||
|
{"stage": "client_initialization"},
|
||||||
|
)
|
||||||
|
|
||||||
|
jira_client = JiraConnector(
|
||||||
|
base_url=jira_base_url, email=jira_email, api_token=jira_api_token
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate date range
|
# Calculate date range
|
||||||
|
# Handle "undefined" strings from frontend
|
||||||
|
if start_date == "undefined" or start_date == "":
|
||||||
|
start_date = None
|
||||||
|
if end_date == "undefined" or end_date == "":
|
||||||
|
end_date = None
|
||||||
|
|
||||||
start_date_str, end_date_str = calculate_date_range(
|
start_date_str, end_date_str = calculate_date_range(
|
||||||
connector, start_date, end_date, default_days_back=365
|
connector, start_date, end_date, default_days_back=365
|
||||||
)
|
)
|
||||||
|
|
@ -422,4 +528,4 @@ async def index_jira_issues(
|
||||||
{"error_type": type(e).__name__},
|
{"error_type": type(e).__name__},
|
||||||
)
|
)
|
||||||
logger.error(f"Failed to index JIRA issues: {e!s}", exc_info=True)
|
logger.error(f"Failed to index JIRA issues: {e!s}", exc_info=True)
|
||||||
return 0, f"Failed to index JIRA issues: {e!s}"
|
return 0, f"Failed to index JIRA issues: {e!s}"
|
||||||
|
|
@ -538,13 +538,13 @@ def validate_connector_config(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# "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": {
|
||||||
"JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"),
|
# "JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"),
|
||||||
"JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"),
|
# "JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"),
|
||||||
},
|
# },
|
||||||
},
|
# },
|
||||||
"CONFLUENCE_CONNECTOR": {
|
"CONFLUENCE_CONNECTOR": {
|
||||||
"required": [
|
"required": [
|
||||||
"CONFLUENCE_BASE_URL",
|
"CONFLUENCE_BASE_URL",
|
||||||
|
|
|
||||||
|
|
@ -1,450 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Info } from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import * as z from "zod";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormDescription,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
|
||||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
|
||||||
import { getConnectorBenefits } from "../connector-benefits";
|
|
||||||
import type { ConnectFormProps } from "../index";
|
|
||||||
|
|
||||||
const jiraConnectorFormSchema = z.object({
|
|
||||||
name: z.string().min(3, {
|
|
||||||
message: "Connector name must be at least 3 characters.",
|
|
||||||
}),
|
|
||||||
base_url: z.string().url({ message: "Please enter a valid Jira base URL." }),
|
|
||||||
email: z.string().email({ message: "Please enter a valid email address." }),
|
|
||||||
api_token: z.string().min(10, {
|
|
||||||
message: "Jira API Token is required and must be valid.",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
|
|
||||||
|
|
||||||
export const JiraConnectForm: 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<JiraConnectorFormValues>({
|
|
||||||
resolver: zodResolver(jiraConnectorFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: "Jira Connector",
|
|
||||||
base_url: "",
|
|
||||||
email: "",
|
|
||||||
api_token: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSubmit = async (values: JiraConnectorFormValues) => {
|
|
||||||
// Prevent multiple submissions
|
|
||||||
if (isSubmittingRef.current || isSubmitting) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSubmittingRef.current = true;
|
|
||||||
try {
|
|
||||||
await onSubmit({
|
|
||||||
name: values.name,
|
|
||||||
connector_type: EnumConnectorName.JIRA_CONNECTOR,
|
|
||||||
config: {
|
|
||||||
JIRA_BASE_URL: values.base_url,
|
|
||||||
JIRA_EMAIL: values.email,
|
|
||||||
JIRA_API_TOKEN: values.api_token,
|
|
||||||
},
|
|
||||||
is_indexable: true,
|
|
||||||
last_indexed_at: null,
|
|
||||||
periodic_indexing_enabled: periodicEnabled,
|
|
||||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
|
||||||
next_scheduled_at: null,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
periodicEnabled,
|
|
||||||
frequencyMinutes,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
isSubmittingRef.current = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<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 Token Required</AlertTitle>
|
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
|
||||||
You'll need a Jira API Token to use this connector. You can create one from{" "}
|
|
||||||
<a
|
|
||||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Atlassian Account 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="jira-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 Jira 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="base_url"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-xs sm:text-sm">Jira Base URL</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="url"
|
|
||||||
placeholder="https://your-domain.atlassian.net"
|
|
||||||
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">
|
|
||||||
The base URL of your Jira instance (e.g., https://your-domain.atlassian.net).
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-xs sm:text-sm">Email Address</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="your-email@example.com"
|
|
||||||
autoComplete="email"
|
|
||||||
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">
|
|
||||||
The email address associated with your Atlassian account.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="api_token"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="text-xs sm:text-sm">API Token</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder="Your API 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 Jira API 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="5" className="text-xs sm:text-sm">
|
|
||||||
Every 5 minutes
|
|
||||||
</SelectItem>
|
|
||||||
<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.JIRA_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 Jira integration:</h4>
|
|
||||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
|
||||||
{getConnectorBenefits(EnumConnectorName.JIRA_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 Jira connector uses the Jira REST API with Basic Authentication to fetch all
|
|
||||||
issues and comments that your account has access to within your Jira instance.
|
|
||||||
</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 read access for this connector to work. The API Token will only be
|
|
||||||
used to read your Jira 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 Token
|
|
||||||
</h4>
|
|
||||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
|
||||||
<li>Log in to your Atlassian account</li>
|
|
||||||
<li>
|
|
||||||
Navigate to{" "}
|
|
||||||
<a
|
|
||||||
href="https://id.atlassian.com/manage-profile/security/api-tokens"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
https://id.atlassian.com/manage-profile/security/api-tokens
|
|
||||||
</a>{" "}
|
|
||||||
in your browser.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Click <strong>Create API token</strong>
|
|
||||||
</li>
|
|
||||||
<li>Enter a label for your token (like "SurfSense Connector")</li>
|
|
||||||
<li>
|
|
||||||
Click <strong>Create</strong>
|
|
||||||
</li>
|
|
||||||
<li>Copy the generated token 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 Token will have access to all projects and issues that your user
|
|
||||||
account can see. Make sure your account has appropriate permissions for the
|
|
||||||
projects you want to index.
|
|
||||||
</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, comments, and basic metadata will be indexed. Jira 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>Jira</strong>{" "}
|
|
||||||
Connector.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Enter your <strong>Jira Instance URL</strong> (e.g.,
|
|
||||||
https://yourcompany.atlassian.net)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Enter your <strong>Email Address</strong> associated with your Atlassian account
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Place your <strong>API Token</strong> in the form field.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Click <strong>Connect</strong> to establish the connection.
|
|
||||||
</li>
|
|
||||||
<li>Once connected, your Jira 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 Jira connector indexes the following data:</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>Issue keys and summaries (e.g., PROJ-123)</li>
|
|
||||||
<li>Issue descriptions</li>
|
|
||||||
<li>Issue comments and discussion threads</li>
|
|
||||||
<li>Issue status, priority, and type information</li>
|
|
||||||
<li>Assignee and reporter information</li>
|
|
||||||
<li>Project information</li>
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { ClickUpConnectForm } from "./components/clickup-connect-form";
|
||||||
import { ConfluenceConnectForm } from "./components/confluence-connect-form";
|
import { ConfluenceConnectForm } from "./components/confluence-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 { 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 { SearxngConnectForm } from "./components/searxng-connect-form";
|
import { SearxngConnectForm } from "./components/searxng-connect-form";
|
||||||
|
|
@ -55,8 +54,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo
|
||||||
return BookStackConnectForm;
|
return BookStackConnectForm;
|
||||||
case "GITHUB_CONNECTOR":
|
case "GITHUB_CONNECTOR":
|
||||||
return GithubConnectForm;
|
return GithubConnectForm;
|
||||||
case "JIRA_CONNECTOR":
|
|
||||||
return JiraConnectForm;
|
|
||||||
case "CLICKUP_CONNECTOR":
|
case "CLICKUP_CONNECTOR":
|
||||||
return ClickUpConnectForm;
|
return ClickUpConnectForm;
|
||||||
case "LUMA_CONNECTOR":
|
case "LUMA_CONNECTOR":
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { KeyRound } from "lucide-react";
|
import { Info, KeyRound } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -12,6 +12,9 @@ export interface JiraConfigProps extends ConnectorConfigProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const JiraConfig: FC<JiraConfigProps> = ({ connector, onConfigChange, onNameChange }) => {
|
export const JiraConfig: FC<JiraConfigProps> = ({ connector, onConfigChange, onNameChange }) => {
|
||||||
|
// Check if this is an OAuth connector (has access_token or _token_encrypted flag)
|
||||||
|
const isOAuth = !!(connector.config?.access_token || connector.config?._token_encrypted);
|
||||||
|
|
||||||
const [baseUrl, setBaseUrl] = useState<string>((connector.config?.JIRA_BASE_URL as string) || "");
|
const [baseUrl, setBaseUrl] = useState<string>((connector.config?.JIRA_BASE_URL as string) || "");
|
||||||
const [email, setEmail] = useState<string>((connector.config?.JIRA_EMAIL as string) || "");
|
const [email, setEmail] = useState<string>((connector.config?.JIRA_EMAIL as string) || "");
|
||||||
const [apiToken, setApiToken] = useState<string>(
|
const [apiToken, setApiToken] = useState<string>(
|
||||||
|
|
@ -19,16 +22,18 @@ export const JiraConfig: FC<JiraConfigProps> = ({ connector, onConfigChange, onN
|
||||||
);
|
);
|
||||||
const [name, setName] = useState<string>(connector.name || "");
|
const [name, setName] = useState<string>(connector.name || "");
|
||||||
|
|
||||||
// Update values when connector changes
|
// Update values when connector changes (only for legacy connectors)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const url = (connector.config?.JIRA_BASE_URL as string) || "";
|
if (!isOAuth) {
|
||||||
const emailVal = (connector.config?.JIRA_EMAIL as string) || "";
|
const url = (connector.config?.JIRA_BASE_URL as string) || "";
|
||||||
const token = (connector.config?.JIRA_API_TOKEN as string) || "";
|
const emailVal = (connector.config?.JIRA_EMAIL as string) || "";
|
||||||
setBaseUrl(url);
|
const token = (connector.config?.JIRA_API_TOKEN as string) || "";
|
||||||
setEmail(emailVal);
|
setBaseUrl(url);
|
||||||
setApiToken(token);
|
setEmail(emailVal);
|
||||||
|
setApiToken(token);
|
||||||
|
}
|
||||||
setName(connector.name || "");
|
setName(connector.name || "");
|
||||||
}, [connector.config, connector.name]);
|
}, [connector.config, connector.name, isOAuth]);
|
||||||
|
|
||||||
const handleBaseUrlChange = (value: string) => {
|
const handleBaseUrlChange = (value: string) => {
|
||||||
setBaseUrl(value);
|
setBaseUrl(value);
|
||||||
|
|
@ -67,6 +72,34 @@ export const JiraConfig: FC<JiraConfigProps> = ({ connector, onConfigChange, onN
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// For OAuth connectors, show simple info message
|
||||||
|
if (isOAuth) {
|
||||||
|
const baseUrl = (connector.config?.base_url as string) || "Unknown";
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* OAuth Info */}
|
||||||
|
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
|
||||||
|
<Info className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-xs sm:text-sm">
|
||||||
|
<p className="font-medium text-xs sm:text-sm">Connected via OAuth</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||||
|
This connector is authenticated using OAuth 2.0. Your Jira instance is:
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-[9px]">{baseUrl}</code>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-2 text-[10px] sm:text-sm">
|
||||||
|
To update your connection, disconnect and reconnect through the OAuth flow.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For legacy API token connectors, show the form
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Connector Name */}
|
{/* Connector Name */}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
||||||
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",
|
||||||
JIRA_CONNECTOR: "jira-connect-form",
|
|
||||||
CLICKUP_CONNECTOR: "clickup-connect-form",
|
CLICKUP_CONNECTOR: "clickup-connect-form",
|
||||||
LUMA_CONNECTOR: "luma-connect-form",
|
LUMA_CONNECTOR: "luma-connect-form",
|
||||||
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
|
CIRCLEBACK_CONNECTOR: "circleback-connect-form",
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,13 @@ export const OAUTH_CONNECTORS = [
|
||||||
connectorType: EnumConnectorName.DISCORD_CONNECTOR,
|
connectorType: EnumConnectorName.DISCORD_CONNECTOR,
|
||||||
authEndpoint: "/api/v1/auth/discord/connector/add/",
|
authEndpoint: "/api/v1/auth/discord/connector/add/",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "jira-connector",
|
||||||
|
title: "Jira",
|
||||||
|
description: "Search Jira issues",
|
||||||
|
connectorType: EnumConnectorName.JIRA_CONNECTOR,
|
||||||
|
authEndpoint: "/api/v1/auth/jira/connector/add/",
|
||||||
|
},
|
||||||
] 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)
|
||||||
|
|
@ -96,12 +103,6 @@ export const OTHER_CONNECTORS = [
|
||||||
description: "Search repositories",
|
description: "Search repositories",
|
||||||
connectorType: EnumConnectorName.GITHUB_CONNECTOR,
|
connectorType: EnumConnectorName.GITHUB_CONNECTOR,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "jira-connector",
|
|
||||||
title: "Jira",
|
|
||||||
description: "Search Jira issues",
|
|
||||||
connectorType: EnumConnectorName.JIRA_CONNECTOR,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "clickup-connector",
|
id: "clickup-connector",
|
||||||
title: "ClickUp",
|
title: "ClickUp",
|
||||||
|
|
|
||||||
|
|
@ -446,7 +446,17 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "JIRA_CONNECTOR":
|
case "JIRA_CONNECTOR": {
|
||||||
|
// Check if this is an OAuth connector (has access_token or _token_encrypted flag)
|
||||||
|
const isJiraOAuth = !!(originalConfig.access_token || originalConfig._token_encrypted);
|
||||||
|
|
||||||
|
if (isJiraOAuth) {
|
||||||
|
// OAuth connectors don't allow editing credentials through the form
|
||||||
|
// Only allow name changes, which are handled separately
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy API token connector - allow editing credentials
|
||||||
if (
|
if (
|
||||||
formData.JIRA_BASE_URL !== originalConfig.JIRA_BASE_URL ||
|
formData.JIRA_BASE_URL !== originalConfig.JIRA_BASE_URL ||
|
||||||
formData.JIRA_EMAIL !== originalConfig.JIRA_EMAIL ||
|
formData.JIRA_EMAIL !== originalConfig.JIRA_EMAIL ||
|
||||||
|
|
@ -464,6 +474,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "LUMA_CONNECTOR":
|
case "LUMA_CONNECTOR":
|
||||||
if (formData.LUMA_API_KEY !== originalConfig.LUMA_API_KEY) {
|
if (formData.LUMA_API_KEY !== originalConfig.LUMA_API_KEY) {
|
||||||
if (!formData.LUMA_API_KEY) {
|
if (!formData.LUMA_API_KEY) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue