From 8c946dfe805e480c5239cee76a27f20c6d048367 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:51:37 +0530 Subject: [PATCH 01/19] feat: enhance document upload UI with accordion functionality --- .../assistant-ui/document-upload-popup.tsx | 40 +++++++++++++++---- .../components/sources/DocumentUploadTab.tsx | 26 ++++++++---- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index d1fa208d2..9734954e1 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -1,5 +1,6 @@ "use client"; +import { Upload } from "lucide-react"; import { useAtomValue } from "jotai"; import { useRouter } from "next/navigation"; import { @@ -85,6 +86,7 @@ const DocumentUploadPopupContent: FC<{ }> = ({ isOpen, onOpenChange }) => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const router = useRouter(); + const [isAccordionExpanded, setIsAccordionExpanded] = useState(false); if (!searchSpaceId) return null; @@ -95,16 +97,40 @@ const DocumentUploadPopupContent: FC<{ return ( - + Upload Document -
-
-
- + + {/* Fixed Header */} +
+ {/* Upload header */} +
+
+ +
+
+

Upload Documents

+

+ Upload and sync your documents to your search space +

- {/* Bottom fade shadow */} -
+
+ + {/* Scrollable Content */} +
+
+
+ +
+
+ {/* Bottom fade shadow - only show when scrolling */} + {isAccordionExpanded && ( +
+ )}
diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index 5280ea850..0062dd2dc 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -31,6 +31,7 @@ import { GridPattern } from "./GridPattern"; interface DocumentUploadTabProps { searchSpaceId: string; onSuccess?: () => void; + onAccordionStateChange?: (isExpanded: boolean) => void; } const audioFileTypes = { @@ -109,11 +110,12 @@ const FILE_TYPE_CONFIG: Record> = { 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 router = useRouter(); const [files, setFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); + const [accordionValue, setAccordionValue] = useState(""); const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom); const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation; const fileInputRef = useRef(null); @@ -154,6 +156,12 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa 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 () => { setUploadProgress(0); trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize); @@ -190,11 +198,11 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} 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" > - - - {t("file_size_limit")} + + + {t("file_size_limit")} @@ -366,11 +374,13 @@ export function DocumentUploadTab({ searchSpaceId, onSuccess }: DocumentUploadTa - -
+ +
From d576607d67049b78b1642af291f6ee66d2efb1fe Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 2 Jan 2026 17:17:49 +0530 Subject: [PATCH 02/19] refactor: improve connector popup UI and date formatting - Adjusted the height of the dialog content in the connector popup for better layout. - Enhanced the last indexed date display with a new function for contextual formatting, providing clearer time references. - Updated various text sizes for consistency across the connector card and dialog header components. - Minor layout adjustments in the connector dialog header and active connectors tab for improved spacing. --- .../assistant-ui/connector-popup.tsx | 4 +- .../components/connector-card.tsx | 57 +++++++++++++++++-- .../components/connector-dialog-header.tsx | 8 +-- .../tabs/active-connectors-tab.tsx | 50 ++++++++++++++-- 4 files changed, 103 insertions(+), 16 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index a955e3972..8fb1e7652 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -189,7 +189,7 @@ export const ConnectorIndicator: FC = () => { )} - + {/* YouTube Crawler View - shown when adding YouTube videos */} {isYouTubeView && searchSpaceId ? ( @@ -272,7 +272,7 @@ export const ConnectorIndicator: FC = () => { {/* Content */}
-
+
= ({ id, title, @@ -86,13 +131,13 @@ export const ConnectorCard: FC = ({ // Show last indexed date for connected connectors if (lastIndexedAt) { return ( - - Last indexed: {format(new Date(lastIndexedAt), "MMM d, yyyy")} + + Last indexed: {formatLastIndexedDate(lastIndexedAt)} ); } // Fallback for connected but never indexed - return Never indexed; + return Never indexed; } return description; @@ -113,9 +158,9 @@ export const ConnectorCard: FC = ({
{title}
-
{getStatusContent()}
+
{getStatusContent()}
{isConnected && documentCount !== undefined && ( -

+

{formatDocumentCount(documentCount)}

)} diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx index a18c79a1f..f1a06de81 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx @@ -24,20 +24,20 @@ export const ConnectorDialogHeader: FC = ({ return (
- + Connectors - + Search across all your apps and data in one place. -
+
= ({ 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) // 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) @@ -148,13 +190,13 @@ export const ActiveConnectorsTab: FC = ({ )}

) : ( -

+

{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"}

)} -

+

{formatDocumentCount(documentCount)}

From 2b01120c2b7e587fd74fb8713465371f27e0a2f6 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 2 Jan 2026 18:22:38 +0530 Subject: [PATCH 03/19] refactor: disable write_todos functionality across chat and UI components - Commented out the write_todos tracking and messaging logic in the stream_new_chat.py file. - Disabled the import and usage of WriteTodosToolUI in the new-chat page component. - Updated related logic in the active connectors tab to remove indexing state handling for write_todos. - These changes are part of a temporary disablement of the write_todos feature for further evaluation. --- .../app/tasks/chat/stream_new_chat.py | 269 +++++++++--------- .../new-chat/[[...chat_id]]/page.tsx | 17 +- .../components/connector-card.tsx | 4 +- .../tabs/active-connectors-tab.tsx | 3 +- 4 files changed, 147 insertions(+), 146 deletions(-) diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 69b75e5c4..3b87c33f1 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -270,7 +270,8 @@ async def stream_new_chat( # Track if we just finished a tool (text flows silently after tools) just_finished_tool: bool = False # 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: nonlocal thinking_step_counter @@ -479,60 +480,60 @@ async def stream_new_chat( status="in_progress", items=last_active_step_items, ) - elif tool_name == "write_todos": - # Track write_todos calls for better messaging - write_todos_call_count += 1 - todos = ( - tool_input.get("todos", []) - if isinstance(tool_input, dict) - else [] - ) - todo_count = len(todos) if isinstance(todos, list) else 0 + # elif tool_name == "write_todos": # Disabled for now + # # Track write_todos calls for better messaging + # write_todos_call_count += 1 + # todos = ( + # tool_input.get("todos", []) + # if isinstance(tool_input, dict) + # else [] + # ) + # todo_count = len(todos) if isinstance(todos, list) else 0 - if write_todos_call_count == 1: - # First call - creating the plan - last_active_step_title = "Creating plan" - last_active_step_items = [f"Defining {todo_count} tasks..."] - else: - # Subsequent calls - updating the plan - # Try to provide context about what's being updated - in_progress_count = ( - sum( - 1 - for t in todos - if isinstance(t, dict) - and t.get("status") == "in_progress" - ) - if isinstance(todos, list) - else 0 - ) - completed_count = ( - sum( - 1 - for t in todos - if isinstance(t, dict) - and t.get("status") == "completed" - ) - if isinstance(todos, list) - else 0 - ) + # if write_todos_call_count == 1: + # # First call - creating the plan + # last_active_step_title = "Creating plan" + # last_active_step_items = [f"Defining {todo_count} tasks..."] + # else: + # # Subsequent calls - updating the plan + # # Try to provide context about what's being updated + # in_progress_count = ( + # sum( + # 1 + # for t in todos + # if isinstance(t, dict) + # and t.get("status") == "in_progress" + # ) + # if isinstance(todos, list) + # else 0 + # ) + # completed_count = ( + # sum( + # 1 + # for t in todos + # if isinstance(t, dict) + # and t.get("status") == "completed" + # ) + # if isinstance(todos, list) + # else 0 + # ) - last_active_step_title = "Updating progress" - last_active_step_items = ( - [ - f"Progress: {completed_count}/{todo_count} completed", - f"In progress: {in_progress_count} tasks", - ] - if completed_count > 0 - else [f"Working on {todo_count} tasks"] - ) + # last_active_step_title = "Updating progress" + # last_active_step_items = ( + # [ + # f"Progress: {completed_count}/{todo_count} completed", + # f"In progress: {in_progress_count} tasks", + # ] + # if completed_count > 0 + # else [f"Working on {todo_count} tasks"] + # ) - yield streaming_service.format_thinking_step( - step_id=tool_step_id, - title=last_active_step_title, - status="in_progress", - items=last_active_step_items, - ) + # yield streaming_service.format_thinking_step( + # step_id=tool_step_id, + # title=last_active_step_title, + # status="in_progress", + # items=last_active_step_items, + # ) elif tool_name == "generate_podcast": podcast_title = ( tool_input.get("podcast_title", "SurfSense Podcast") @@ -596,10 +597,12 @@ async def stream_new_chat( raw_output = event.get("data", {}).get("output", "") # Handle deepagents' write_todos Command object specially - if tool_name == "write_todos" and hasattr(raw_output, "update"): - # deepagents returns a Command object - extract todos directly - tool_output = extract_todos_from_deepagents(raw_output) - elif hasattr(raw_output, "content"): + # Disabled for now + # if tool_name == "write_todos" and hasattr(raw_output, "update"): + # # deepagents returns a Command object - extract todos directly + # 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 content = raw_output.content # If content is a string that looks like JSON, try to parse it @@ -758,63 +761,63 @@ async def stream_new_chat( status="completed", items=completed_items, ) - elif tool_name == "write_todos": - # Build completion items for planning/updating - if isinstance(tool_output, dict): - todos = tool_output.get("todos", []) - todo_count = len(todos) if isinstance(todos, list) else 0 - completed_count = ( - sum( - 1 - for t in todos - if isinstance(t, dict) - and t.get("status") == "completed" - ) - if isinstance(todos, list) - else 0 - ) - in_progress_count = ( - sum( - 1 - for t in todos - if isinstance(t, dict) - and t.get("status") == "in_progress" - ) - if isinstance(todos, list) - else 0 - ) + # elif tool_name == "write_todos": # Disabled for now + # # Build completion items for planning/updating + # if isinstance(tool_output, dict): + # todos = tool_output.get("todos", []) + # todo_count = len(todos) if isinstance(todos, list) else 0 + # completed_count = ( + # sum( + # 1 + # for t in todos + # if isinstance(t, dict) + # and t.get("status") == "completed" + # ) + # if isinstance(todos, list) + # else 0 + # ) + # in_progress_count = ( + # sum( + # 1 + # for t in todos + # if isinstance(t, dict) + # and t.get("status") == "in_progress" + # ) + # if isinstance(todos, list) + # else 0 + # ) - # Use context-aware completion message - if last_active_step_title == "Creating plan": - completed_items = [f"Created {todo_count} tasks"] - else: - # Updating progress - show stats - completed_items = [ - f"Progress: {completed_count}/{todo_count} completed", - ] - if in_progress_count > 0: - # Find the currently in-progress task name - in_progress_task = next( - ( - t.get("content", "")[:40] - for t in todos - if isinstance(t, dict) - and t.get("status") == "in_progress" - ), - None, - ) - if in_progress_task: - completed_items.append( - f"Current: {in_progress_task}..." - ) - else: - completed_items = ["Plan updated"] - yield streaming_service.format_thinking_step( - step_id=original_step_id, - title=last_active_step_title, - status="completed", - items=completed_items, - ) + # # Use context-aware completion message + # if last_active_step_title == "Creating plan": + # completed_items = [f"Created {todo_count} tasks"] + # else: + # # Updating progress - show stats + # completed_items = [ + # f"Progress: {completed_count}/{todo_count} completed", + # ] + # if in_progress_count > 0: + # # Find the currently in-progress task name + # in_progress_task = next( + # ( + # t.get("content", "")[:40] + # for t in todos + # if isinstance(t, dict) + # and t.get("status") == "in_progress" + # ), + # None, + # ) + # if in_progress_task: + # completed_items.append( + # f"Current: {in_progress_task}..." + # ) + # else: + # completed_items = ["Plan updated"] + # yield streaming_service.format_thinking_step( + # step_id=original_step_id, + # title=last_active_step_title, + # status="completed", + # items=completed_items, + # ) elif tool_name == "ls": # Build completion items showing file names found if isinstance(tool_output, dict): @@ -992,27 +995,27 @@ async def stream_new_chat( yield streaming_service.format_terminal_info( "Knowledge base search completed", "success" ) - elif tool_name == "write_todos": - # Stream the full write_todos result so frontend can render the Plan component - yield streaming_service.format_tool_output_available( - tool_call_id, - tool_output - if isinstance(tool_output, dict) - else {"result": tool_output}, - ) - # Send terminal message with plan info - if isinstance(tool_output, dict): - todos = tool_output.get("todos", []) - todo_count = len(todos) if isinstance(todos, list) else 0 - yield streaming_service.format_terminal_info( - f"Plan created ({todo_count} tasks)", - "success", - ) - else: - yield streaming_service.format_terminal_info( - "Plan created", - "success", - ) + # elif tool_name == "write_todos": # Disabled for now + # # Stream the full write_todos result so frontend can render the Plan component + # yield streaming_service.format_tool_output_available( + # tool_call_id, + # tool_output + # if isinstance(tool_output, dict) + # else {"result": tool_output}, + # ) + # # Send terminal message with plan info + # if isinstance(tool_output, dict): + # todos = tool_output.get("todos", []) + # todo_count = len(todos) if isinstance(todos, list) else 0 + # yield streaming_service.format_terminal_info( + # f"Plan created ({todo_count} tasks)", + # "success", + # ) + # else: + # yield streaming_service.format_terminal_info( + # "Plan created", + # "success", + # ) else: # Default handling for other tools yield streaming_service.format_tool_output_available( diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 35a096497..b1abd647f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -20,7 +20,7 @@ import { } from "@/atoms/chat/mentioned-documents.atom"; import { clearPlanOwnerRegistry, - extractWriteTodosFromContent, + // extractWriteTodosFromContent, hydratePlanStateAtom, } from "@/atoms/chat/plan-state.atom"; 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 { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; 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 { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter"; import { @@ -199,7 +199,7 @@ const TOOLS_WITH_UI = new Set([ "link_preview", "display_image", "scrape_webpage", - "write_todos", + // "write_todos", // Disabled for now ]); /** @@ -291,10 +291,11 @@ export default function NewChatPage() { restoredThinkingSteps.set(`msg-${msg.id}`, steps); } // Hydrate write_todos plan state from persisted tool calls - const writeTodosCalls = extractWriteTodosFromContent(msg.content); - for (const todoData of writeTodosCalls) { - hydratePlanState(todoData); - } + // Disabled for now + // const writeTodosCalls = extractWriteTodosFromContent(msg.content); + // for (const todoData of writeTodosCalls) { + // hydratePlanState(todoData); + // } } if (msg.role === "user") { const docs = extractMentionedDocuments(msg.content); @@ -911,7 +912,7 @@ export default function NewChatPage() { - + {/* Disabled for now */}
= ({ !isConnected && "shadow-xs" )} onClick={isConnected ? onManage : onConnect} - disabled={isConnecting || isIndexing} + disabled={isConnecting} > {isConnecting ? ( - ) : isIndexing ? ( - "Syncing..." ) : isConnected ? ( "Manage" ) : id === "youtube-crawler" ? ( diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index 12e0d4472..ec3177e28 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -205,9 +205,8 @@ export const ActiveConnectorsTab: FC = ({ 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" onClick={onManage ? () => onManage(connector) : undefined} - disabled={isIndexing} > - {isIndexing ? "Syncing..." : "Manage"} + Manage
); From c5b184d4758355696b2c8179a47af183c5ee6194 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Fri, 2 Jan 2026 20:07:14 +0530 Subject: [PATCH 04/19] feat: add Notion OAuth integration and connector routes - Introduced Notion OAuth support with new environment variables for client ID, client secret, and redirect URI. - Implemented Notion connector routes for OAuth flow, including authorization and callback handling. - Updated existing components to accommodate Notion integration, including validation changes and connector configuration. - Enhanced the Notion indexer to utilize OAuth access tokens instead of integration tokens. - Adjusted UI components to reflect the new Notion connector without requiring special configuration. --- surfsense_backend/.env.example | 6 +- surfsense_backend/app/config/__init__.py | 5 + .../app/connectors/notion_history.py | 2 +- surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/notion_add_connector_route.py | 246 ++++++++++++++++++ .../connector_indexers/notion_indexer.py | 59 ++--- surfsense_backend/app/utils/validators.py | 8 +- .../components/circleback-connect-form.tsx | 2 +- .../connector-popup/connect-forms/index.tsx | 3 - .../connector-configs/index.tsx | 5 +- .../views/connector-connect-view.tsx | 1 - .../views/connector-edit-view.tsx | 21 +- .../views/indexing-configuration-view.tsx | 34 ++- .../constants/connector-constants.ts | 15 +- 14 files changed, 333 insertions(+), 76 deletions(-) create mode 100644 surfsense_backend/app/routes/notion_add_connector_route.py diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 91a0cb42f..3ee063e15 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -38,7 +38,11 @@ GOOGLE_OAUTH_CLIENT_SECRET=GOCSV 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_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 + +# Notion OAuth for Notion Connector +NOTION_CLIENT_ID=your_notion_client_id +NOTION_CLIENT_SECRET=your_notion_client_secret +NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback # Airtable OAuth for Aitable Connector AIRTABLE_CLIENT_ID=your_airtable_client_id diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 9c503fb18..61e150bf3 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -90,6 +90,11 @@ class Config: AIRTABLE_CLIENT_SECRET = os.getenv("AIRTABLE_CLIENT_SECRET") 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") + # LLM instances are now managed per-user through the LLMConfig system # Legacy environment variables removed in favor of user-specific configurations diff --git a/surfsense_backend/app/connectors/notion_history.py b/surfsense_backend/app/connectors/notion_history.py index 81f6642f1..1f6300575 100644 --- a/surfsense_backend/app/connectors/notion_history.py +++ b/surfsense_backend/app/connectors/notion_history.py @@ -7,7 +7,7 @@ class NotionHistoryConnector: Initialize the NotionPageFetcher with a token. Args: - token (str): Notion integration token + token (str): Notion OAuth access token """ self.notion = AsyncClient(auth=token) diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 3c18650ae..1246dfe39 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -18,6 +18,7 @@ from .google_gmail_add_connector_route import ( from .logs_routes import router as logs_router from .luma_add_connector_route import router as luma_add_connector_router from .new_chat_routes import router as new_chat_router +from .notion_add_connector_route import router as notion_add_connector_router from .new_llm_config_routes import router as new_llm_config_router from .notes_routes import router as notes_router from .podcasts_routes import router as podcasts_router @@ -40,6 +41,7 @@ router.include_router(google_gmail_add_connector_router) router.include_router(google_drive_add_connector_router) router.include_router(airtable_add_connector_router) router.include_router(luma_add_connector_router) +router.include_router(notion_add_connector_router) router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py new file mode 100644 index 000000000..38c435ff1 --- /dev/null +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -0,0 +1,246 @@ +""" +Notion Connector OAuth Routes. + +Handles OAuth 2.0 authentication flow for Notion connector. +""" + +import base64 +import json +import logging +from uuid import UUID + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import RedirectResponse +from pydantic import ValidationError +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select + +from app.config import config +from app.db import ( + SearchSourceConnector, + SearchSourceConnectorType, + User, + get_async_session, +) +from app.users import current_active_user + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Notion OAuth endpoints +AUTHORIZATION_URL = "https://api.notion.com/v1/oauth/authorize" +TOKEN_URL = "https://api.notion.com/v1/oauth/token" + + +def make_basic_auth_header(client_id: str, client_secret: str) -> str: + """Create Basic Auth header for Notion OAuth.""" + 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." + ) + + # Generate state parameter + state_payload = json.dumps( + { + "space_id": space_id, + "user_id": str(user.id), + } + ) + state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode() + + # Build authorization URL + from urllib.parse import urlencode + + auth_params = { + "client_id": config.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, + state: str, + session: AsyncSession = Depends(get_async_session), +): + """ + Handle Notion OAuth callback. + + Args: + request: FastAPI request object + code: Authorization code from Notion + state: State parameter containing user/space info + session: Database session + + Returns: + Redirect response to frontend + """ + try: + # Decode and parse the state + try: + decoded_state = base64.urlsafe_b64decode(state.encode()).decode() + data = json.loads(decoded_state) + except Exception as e: + raise HTTPException( + status_code=400, detail=f"Invalid state parameter: {e!s}" + ) from e + + user_id = UUID(data["user_id"]) + space_id = data["space_id"] + + # Exchange authorization code for access token + auth_header = make_basic_auth_header( + config.NOTION_CLIENT_ID, config.NOTION_CLIENT_SECRET + ) + + token_data = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": config.NOTION_REDIRECT_URI, + } + + 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() + + # Notion returns access_token and workspace information + # Store the access token and workspace info in connector config + connector_config = { + "access_token": token_json["access_token"], + "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"), + } + + # 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 + diff --git a/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py b/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py index 332d3e39d..b42626667 100644 --- a/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/notion_indexer.py @@ -2,7 +2,7 @@ Notion connector indexer. """ -from datetime import datetime, timedelta +from datetime import datetime from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession @@ -20,6 +20,7 @@ from app.utils.document_converters import ( from .base import ( build_document_metadata_string, + calculate_date_range, check_document_by_unique_identifier, get_connector_by_id, get_current_timestamp, @@ -91,16 +92,16 @@ async def index_notion_pages( f"Connector with ID {connector_id} not found or is not a Notion connector", ) - # Get the Notion token from the connector config - notion_token = connector.config.get("NOTION_INTEGRATION_TOKEN") + # Get the Notion access token from the connector config (OAuth-based) + notion_token = connector.config.get("access_token") if not notion_token: await task_logger.log_task_failure( log_entry, - f"Notion integration token not found in connector config for connector {connector_id}", - "Missing Notion token", + f"Notion access token not found in connector config for connector {connector_id}", + "Missing Notion access token", {"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 await task_logger.log_task_progress( @@ -111,38 +112,24 @@ async def index_notion_pages( logger.info(f"Initializing Notion client for connector {connector_id}") - # Calculate date range - if start_date is None or end_date is None: - # Fall back to calculating dates - calculated_end_date = datetime.now() - calculated_start_date = calculated_end_date - timedelta( - days=365 - ) # Check for last 1 year of pages + # 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 - # Use calculated dates if not provided - if start_date is None: - start_date_iso = calculated_start_date.strftime("%Y-%m-%dT%H:%M:%SZ") - 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" - ) + # Calculate date range using the shared utility function + start_date_str, end_date_str = calculate_date_range( + connector, start_date, end_date, default_days_back=365 + ) - if end_date is None: - end_date_iso = calculated_end_date.strftime("%Y-%m-%dT%H:%M:%SZ") - else: - # Convert YYYY-MM-DD to ISO format - end_date_iso = datetime.strptime(end_date, "%Y-%m-%d").strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - else: - # Convert provided dates to ISO format for Notion API - start_date_iso = datetime.strptime(start_date, "%Y-%m-%d").strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - end_date_iso = datetime.strptime(end_date, "%Y-%m-%d").strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) + # Convert YYYY-MM-DD to ISO format for Notion API + start_date_iso = datetime.strptime(start_date_str, "%Y-%m-%d").strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + end_date_iso = datetime.strptime(end_date_str, "%Y-%m-%d").strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) notion_client = NotionHistoryConnector(token=notion_token) diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index 6b69fb3e1..1e76afc67 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -515,7 +515,13 @@ def validate_connector_config( }, "SLACK_CONNECTOR": {"required": ["SLACK_BOT_TOKEN"], "validators": {}}, "NOTION_CONNECTOR": { - "required": ["NOTION_INTEGRATION_TOKEN"], + "required": ["access_token"], # OAuth-based only + "optional": [ + "workspace_id", # OAuth fields + "workspace_name", + "workspace_icon", + "bot_id", + ], "validators": {}, }, "GITHUB_CONNECTOR": { diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/circleback-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/circleback-connect-form.tsx index 75a3ab00b..cd7f1a888 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/circleback-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/circleback-connect-form.tsx @@ -1,7 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Info, Webhook } from "lucide-react"; +import { Webhook } from "lucide-react"; import type { FC } from "react"; import { useRef } from "react"; import { useForm } from "react-hook-form"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx index 7bca3a1bc..e84cb3b96 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx @@ -11,7 +11,6 @@ import { JiraConnectForm } from "./components/jira-connect-form"; import { LinearConnectForm } from "./components/linear-connect-form"; import { LinkupApiConnectForm } from "./components/linkup-api-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form"; -import { NotionConnectForm } from "./components/notion-connect-form"; import { SearxngConnectForm } from "./components/searxng-connect-form"; import { SlackConnectForm } from "./components/slack-connect-form"; import { TavilyApiConnectForm } from "./components/tavily-api-connect-form"; @@ -59,8 +58,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo return SlackConnectForm; case "DISCORD_CONNECTOR": return DiscordConnectForm; - case "NOTION_CONNECTOR": - return NotionConnectForm; case "CONFLUENCE_CONNECTOR": return ConfluenceConnectForm; case "BOOKSTACK_CONNECTOR": diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx index c31a4645a..793b961e3 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx @@ -15,7 +15,6 @@ import { JiraConfig } from "./components/jira-config"; import { LinearConfig } from "./components/linear-config"; import { LinkupApiConfig } from "./components/linkup-api-config"; import { LumaConfig } from "./components/luma-config"; -import { NotionConfig } from "./components/notion-config"; import { SearxngConfig } from "./components/searxng-config"; import { SlackConfig } from "./components/slack-config"; import { TavilyApiConfig } from "./components/tavily-api-config"; @@ -56,8 +55,6 @@ export function getConnectorConfigComponent( return SlackConfig; case "DISCORD_CONNECTOR": return DiscordConfig; - case "NOTION_CONNECTOR": - return NotionConfig; case "CONFLUENCE_CONNECTOR": return ConfluenceConfig; case "BOOKSTACK_CONNECTOR": @@ -72,7 +69,7 @@ export function getConnectorConfigComponent( return LumaConfig; case "CIRCLEBACK_CONNECTOR": 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: return null; } diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx index dfd91fe8b..fd0f62fa0 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -55,7 +55,6 @@ export const ConnectorConnectView: FC = ({ 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", BOOKSTACK_CONNECTOR: "bookstack-connect-form", GITHUB_CONNECTOR: "github-connect-form", diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx index 6d43e6ffc..7776c9a9d 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -59,6 +59,7 @@ export const ConnectorEditView: FC = ({ const [isScrolled, setIsScrolled] = useState(false); const [hasMoreContent, setHasMoreContent] = useState(false); const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + const [isQuickIndexing, setIsQuickIndexing] = useState(false); const scrollContainerRef = useRef(null); const checkScrollState = useCallback(() => { @@ -94,6 +95,13 @@ export const ConnectorEditView: FC = ({ }; }, [checkScrollState]); + // Reset local quick indexing state when indexing completes + useEffect(() => { + if (!isIndexing) { + setIsQuickIndexing(false); + } + }, [isIndexing]); + const handleDisconnectClick = () => { setShowDisconnectConfirm(true); }; @@ -107,6 +115,13 @@ export const ConnectorEditView: FC = ({ setShowDisconnectConfirm(false); }; + const handleQuickIndex = useCallback(() => { + if (onQuickIndex) { + setIsQuickIndexing(true); + onQuickIndex(); + } + }, [onQuickIndex]); + return (
{/* Fixed Header */} @@ -146,11 +161,11 @@ export const ConnectorEditView: FC = ({ + {/* Back button - only show if not from OAuth */} + {!isFromOAuth && ( + + )} {/* Success header */}
@@ -187,15 +193,7 @@ export const IndexingConfigurationView: FC = ({
{/* Fixed Footer - Action buttons */} -
- +
); })} + {/* Loading indicator for additional pages */} + {isLoadingMore && ( +
+
+
+ )}
)}
From 431ea44b5691fb39e661f7cf71763df3016a3c71 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sat, 3 Jan 2026 23:06:09 +0530 Subject: [PATCH 13/19] feat: enhance periodic indexing configuration with detailed validation and UI options - Updated the SearchSourceConnectorBase class to include detailed documentation on supported periodic indexing frequencies. - Added "Every 5 minutes" option to the frequency selection in multiple connector forms (BookStack, ClickUp, Confluence, Discord, Elasticsearch, Github, Jira, Luma, Slack) to improve user experience and flexibility in scheduling. --- surfsense_backend/app/schemas/search_source_connector.py | 7 ++++++- .../connector-popup/components/periodic-sync-config.tsx | 3 +++ .../connect-forms/components/bookstack-connect-form.tsx | 3 +++ .../connect-forms/components/clickup-connect-form.tsx | 3 +++ .../connect-forms/components/confluence-connect-form.tsx | 3 +++ .../connect-forms/components/discord-connect-form.tsx | 3 +++ .../components/elasticsearch-connect-form.tsx | 3 +++ .../connect-forms/components/github-connect-form.tsx | 3 +++ .../connect-forms/components/jira-connect-form.tsx | 3 +++ .../connect-forms/components/luma-connect-form.tsx | 3 +++ .../connect-forms/components/slack-connect-form.tsx | 3 +++ .../connector-popup/constants/connector-popup.schemas.ts | 2 +- 12 files changed, 37 insertions(+), 2 deletions(-) diff --git a/surfsense_backend/app/schemas/search_source_connector.py b/surfsense_backend/app/schemas/search_source_connector.py index 1e8a7a38d..b0d8ebc3a 100644 --- a/surfsense_backend/app/schemas/search_source_connector.py +++ b/surfsense_backend/app/schemas/search_source_connector.py @@ -30,7 +30,12 @@ class SearchSourceConnectorBase(BaseModel): @model_validator(mode="after") 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 not self.is_indexable: raise ValueError( diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx index 0e1be72b8..f390b1d1b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx @@ -50,6 +50,9 @@ export const PeriodicSyncConfig: FC = ({ + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/bookstack-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/bookstack-connect-form.tsx index b0488854f..2b7123d78 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/bookstack-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/bookstack-connect-form.tsx @@ -256,6 +256,9 @@ export const BookStackConnectForm: FC = ({ onSubmit, isSubmitt + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx index 5be0045ff..9f33c6ed9 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/clickup-connect-form.tsx @@ -209,6 +209,9 @@ export const ClickUpConnectForm: FC = ({ onSubmit, isSubmittin + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/confluence-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/confluence-connect-form.tsx index 9f0921bd8..83f6c6ec7 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/confluence-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/confluence-connect-form.tsx @@ -263,6 +263,9 @@ export const ConfluenceConnectForm: FC = ({ onSubmit, isSubmit + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx index e0f253129..8f4fa1a47 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx @@ -209,6 +209,9 @@ export const DiscordConnectForm: FC = ({ onSubmit, isSubmittin + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx index 24640f7e3..3ceca0930 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/elasticsearch-connect-form.tsx @@ -616,6 +616,9 @@ export const ElasticsearchConnectForm: FC = ({ onSubmit, isSub + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/github-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/github-connect-form.tsx index 772acb489..b2b371ed8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/github-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/github-connect-form.tsx @@ -269,6 +269,9 @@ export const GithubConnectForm: FC = ({ onSubmit, isSubmitting + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx index d048d9c66..0499554b4 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx @@ -262,6 +262,9 @@ export const JiraConnectForm: FC = ({ onSubmit, isSubmitting } + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/luma-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/luma-connect-form.tsx index 2e95fb445..03ab78ddf 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/luma-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/luma-connect-form.tsx @@ -209,6 +209,9 @@ export const LumaConnectForm: FC = ({ onSubmit, isSubmitting } + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx index da4605473..3952144e6 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx @@ -211,6 +211,9 @@ export const SlackConnectForm: FC = ({ onSubmit, isSubmitting + + Every 5 minutes + Every 15 minutes diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts index 3fcdf352f..65456689c 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts @@ -39,7 +39,7 @@ export type IndexingConfigState = z.infer; /** * 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", }); From 0fe94bfcf3e77484f594341a41b14eb3cd87e3a1 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:30:00 +0530 Subject: [PATCH 14/19] feat: add Slack OAuth integration and connector routes - Introduced Slack OAuth support with new environment variables for client ID, client secret, and redirect URI. - Implemented Slack connector routes for OAuth flow, including authorization and callback handling. - Updated configuration to support both new OAuth format and legacy token handling. - Enhanced the Slack indexer to decrypt tokens when necessary, ensuring compatibility with existing encrypted credentials. - Removed outdated Slack connector UI components and adjusted frontend logic to reflect the new integration. --- surfsense_backend/.env.example | 5 + surfsense_backend/app/config/__init__.py | 5 + surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/slack_add_connector_route.py | 336 ++++++++++++++ .../app/schemas/search_source_connector.py | 2 +- .../tasks/connector_indexers/slack_indexer.py | 28 +- surfsense_backend/app/utils/validators.py | 17 +- .../components/slack-connect-form.tsx | 429 ------------------ .../connector-popup/connect-forms/index.tsx | 3 - .../components/slack-config.tsx | 81 +--- .../views/connector-connect-view.tsx | 1 - .../constants/connector-constants.ts | 13 +- 12 files changed, 411 insertions(+), 511 deletions(-) create mode 100644 surfsense_backend/app/routes/slack_add_connector_route.py delete mode 100644 surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 83656a910..2cacedc21 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -54,6 +54,11 @@ 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 # Examples: # # Get sentence transformers embeddings diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 7c7703470..f69d1c1a3 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -100,6 +100,11 @@ class Config: 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") + # LLM instances are now managed per-user through the LLMConfig system # Legacy environment variables removed in favor of user-specific configurations diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 1d1fa39ad..05020deff 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -26,6 +26,7 @@ from .podcasts_routes import router as podcasts_router from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router +from .slack_add_connector_route import router as slack_add_connector_router router = APIRouter() @@ -44,6 +45,7 @@ router.include_router(airtable_add_connector_router) router.include_router(linear_add_connector_router) router.include_router(luma_add_connector_router) router.include_router(notion_add_connector_router) +router.include_router(slack_add_connector_router) router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py new file mode 100644 index 000000000..1bbb4f5f1 --- /dev/null +++ b/surfsense_backend/app/routes/slack_add_connector_route.py @@ -0,0 +1,336 @@ +""" +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.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-..." }, ... } + 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" + ) + + # 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 in connector config + connector_config = { + "bot_token": token_encryption.encrypt_token(bot_token), + "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 diff --git a/surfsense_backend/app/schemas/search_source_connector.py b/surfsense_backend/app/schemas/search_source_connector.py index b0d8ebc3a..dbe4dce1f 100644 --- a/surfsense_backend/app/schemas/search_source_connector.py +++ b/surfsense_backend/app/schemas/search_source_connector.py @@ -31,7 +31,7 @@ class SearchSourceConnectorBase(BaseModel): @model_validator(mode="after") def validate_periodic_indexing(self): """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. diff --git a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py index 5119aba2e..4c4191a4e 100644 --- a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py @@ -17,6 +17,7 @@ from app.utils.document_converters import ( generate_content_hash, generate_unique_identifier_hash, ) +from app.utils.oauth_security import TokenEncryption from .base import ( build_document_metadata_markdown, @@ -93,7 +94,10 @@ async def index_slack_messages( ) # Get the Slack token from the connector config - slack_token = connector.config.get("SLACK_BOT_TOKEN") + # Support both new OAuth format (bot_token) and old API format (SLACK_BOT_TOKEN) + config_data = connector.config.copy() + slack_token = config_data.get("bot_token") or config_data.get("SLACK_BOT_TOKEN") + if not slack_token: await task_logger.log_task_failure( log_entry, @@ -103,6 +107,22 @@ async def index_slack_messages( ) return 0, "Slack token not found in connector config" + # Decrypt token if it's encrypted (OAuth format) + token_encrypted = config_data.get("_token_encrypted", False) + if token_encrypted and config.SECRET_KEY: + try: + token_encryption = TokenEncryption(config.SECRET_KEY) + slack_token = token_encryption.decrypt_token(slack_token) + logger.info(f"Decrypted Slack bot token for connector {connector_id}") + except Exception as e: + await task_logger.log_task_failure( + log_entry, + f"Failed to decrypt Slack token for connector {connector_id}: {e!s}", + "Token decryption failed", + {"error_type": "TokenDecryptionError"}, + ) + return 0, f"Failed to decrypt Slack token: {e!s}" + # Initialize Slack client await task_logger.log_task_progress( log_entry, @@ -112,6 +132,12 @@ async def index_slack_messages( slack_client = SlackHistory(token=slack_token) + # 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 await task_logger.log_task_progress( log_entry, diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index d6622bafd..8db6ed4a3 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -513,7 +513,22 @@ def validate_connector_config( ], "validators": {}, }, - "SLACK_CONNECTOR": {"required": ["SLACK_BOT_TOKEN"], "validators": {}}, + # "SLACK_CONNECTOR": { + # "required": [], # OAuth uses bot_token (encrypted), legacy uses SLACK_BOT_TOKEN + # "optional": [ + # "bot_token", + # "SLACK_BOT_TOKEN", + # "bot_user_id", + # "team_id", + # "team_name", + # "token_type", + # "expires_in", + # "expires_at", + # "scope", + # "_token_encrypted", + # ], + # "validators": {}, + # }, "GITHUB_CONNECTOR": { "required": ["GITHUB_PAT", "repo_full_names"], "validators": { diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx deleted file mode 100644 index 3952144e6..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/slack-connect-form.tsx +++ /dev/null @@ -1,429 +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; - -export const SlackConnectForm: FC = ({ onSubmit, isSubmitting }) => { - const isSubmittingRef = useRef(false); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); - const form = useForm({ - resolver: zodResolver(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 ( -
- - -
- Bot User OAuth Token Required - - You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack - app and get the token from{" "} - - Slack API Dashboard - - -
-
- -
-
- - ( - - Connector Name - - - - - A friendly name to identify this connector. - - - - )} - /> - - ( - - Slack Bot User OAuth Token - - - - - Your Bot User OAuth Token will be encrypted and stored securely. It typically - starts with "xoxb-". - - - - )} - /> - - {/* Indexing Configuration */} -
-

Indexing Configuration

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

Enable Periodic Sync

-

- Automatically re-index at regular intervals -

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

What you get with Slack integration:

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

How it works

-

- 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. -

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

Authorization

- - - - Bot User OAuth Token Required - - - 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. - - - -
-
-

- Step 1: Create a Slack App -

-
    -
  1. - Go to{" "} - - https://api.slack.com/apps - -
  2. -
  3. - Click Create New App and choose "From scratch" -
  4. -
  5. Enter an app name and select your workspace
  6. -
  7. - Click Create App -
  8. -
-
- -
-

- Step 2: Configure Bot Scopes -

-
    -
  1. - Navigate to OAuth & Permissions in the sidebar -
  2. -
  3. - Under Bot Token Scopes, add the following scopes: -
      -
    • - channels:read - - View basic information about public channels -
    • -
    • - channels:history - - View messages in public channels -
    • -
    • - groups:read - View - basic information about private channels -
    • -
    • - groups:history - - View messages in private channels -
    • -
    • - im:read - View - basic information about direct messages -
    • -
    • - im:history - View - messages in direct messages -
    • -
    -
  4. -
-
- -
-

- Step 3: Install App to Workspace -

-
    -
  1. - Go to Install App in the sidebar -
  2. -
  3. - Click Install to Workspace -
  4. -
  5. - Review the permissions and click Allow -
  6. -
  7. - Copy the Bot User OAuth Token from the "OAuth & - Permissions" page (starts with "xoxb-") -
  8. -
-
-
-
-
- -
-
-

Indexing

-
    -
  1. - Navigate to the Connector Dashboard and select the Slack{" "} - Connector. -
  2. -
  3. - Place the Bot User OAuth Token in the form field. -
  4. -
  5. - Click Connect to establish the connection. -
  6. -
  7. Once connected, your Slack messages will be indexed automatically.
  8. -
- - - - What Gets Indexed - -

The Slack connector indexes the following data:

-
    -
  • Messages from all accessible channels (public and private)
  • -
  • Direct messages (if bot has access)
  • -
  • Message timestamps and metadata
  • -
  • Thread replies and conversations
  • -
-
-
-
-
-
-
-
-
- ); -}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx index 55169fadd..807d4cb7a 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx @@ -11,7 +11,6 @@ import { JiraConnectForm } from "./components/jira-connect-form"; import { LinkupApiConnectForm } from "./components/linkup-api-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form"; import { SearxngConnectForm } from "./components/searxng-connect-form"; -import { SlackConnectForm } from "./components/slack-connect-form"; import { TavilyApiConnectForm } from "./components/tavily-api-connect-form"; export interface ConnectFormProps { @@ -51,8 +50,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo return BaiduSearchApiConnectForm; case "ELASTICSEARCH_CONNECTOR": return ElasticsearchConnectForm; - case "SLACK_CONNECTOR": - return SlackConnectForm; case "DISCORD_CONNECTOR": return DiscordConnectForm; case "CONFLUENCE_CONNECTOR": diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx index 73ae6a4f3..58293c4de 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/slack-config.tsx @@ -1,84 +1,27 @@ "use client"; -import { KeyRound } from "lucide-react"; +import { Info } 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 SlackConfigProps extends ConnectorConfigProps { onNameChange?: (name: string) => void; } -export const SlackConfig: FC = ({ connector, onConfigChange, onNameChange }) => { - const [botToken, setBotToken] = useState( - (connector.config?.SLACK_BOT_TOKEN as string) || "" - ); - const [name, setName] = useState(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); - } - }; - +export const SlackConfig: FC = () => { return (
- {/* Connector Name */} -
-
- - handleNameChange(e.target.value)} - placeholder="My Slack Connector" - className="border-slate-400/20 focus-visible:border-slate-400/40" - /> -

- A friendly name to identify this connector. -

+
+
+
-
- - {/* Configuration */} -
-
-

Configuration

-
- -
- - handleBotTokenChange(e.target.value)} - placeholder="Begins with xoxb-..." - className="border-slate-400/20 focus-visible:border-slate-400/40" - /> -

- Update your Bot User OAuth Token if needed. +

+

Add Bot to Channels

+

+ Before indexing, add the SurfSense bot to each channel you want to index. The bot can + only access messages from channels it's been added to. Type{" "} + /invite @SurfSense in + any channel to add it.

diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx index a02ae5088..5437426c8 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -52,7 +52,6 @@ export const ConnectorConnectView: FC = ({ LINKUP_API: "linkup-api-connect-form", BAIDU_SEARCH_API: "baidu-search-api-connect-form", ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form", - SLACK_CONNECTOR: "slack-connect-form", DISCORD_CONNECTOR: "discord-connect-form", CONFLUENCE_CONNECTOR: "confluence-connect-form", BOOKSTACK_CONNECTOR: "bookstack-connect-form", diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 0ea263430..111b7485d 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -44,6 +44,13 @@ export const OAUTH_CONNECTORS = [ 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/", + }, ] as const; // Content Sources (tools that extract and import content from external sources) @@ -64,12 +71,6 @@ export const CRAWLERS = [ // Non-OAuth Connectors (redirect to old connector config pages) export const OTHER_CONNECTORS = [ - { - id: "slack-connector", - title: "Slack", - description: "Search Slack messages", - connectorType: EnumConnectorName.SLACK_CONNECTOR, - }, { id: "discord-connector", title: "Discord", From 81e4a4ada06343e27d7cdcf3e3b757b4c9b11d69 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:38:19 +0530 Subject: [PATCH 15/19] feat: database driven refresh tokens for slack oauth connector --- .../app/connectors/slack_history.py | 217 ++++++++++++++++-- .../app/routes/slack_add_connector_route.py | 146 +++++++++++- .../app/schemas/slack_auth_credentials.py | 76 ++++++ .../tasks/connector_indexers/slack_indexer.py | 45 +--- 4 files changed, 426 insertions(+), 58 deletions(-) create mode 100644 surfsense_backend/app/schemas/slack_auth_credentials.py diff --git a/surfsense_backend/app/connectors/slack_history.py b/surfsense_backend/app/connectors/slack_history.py index 36160c30b..6a016394e 100644 --- a/surfsense_backend/app/connectors/slack_history.py +++ b/surfsense_backend/app/connectors/slack_history.py @@ -12,6 +12,14 @@ from typing import Any from slack_sdk import WebClient 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 @@ -19,25 +27,195 @@ logger = logging.getLogger(__name__) # Added logger class SlackHistory: """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. 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: + # 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 + if self._credentials is None: + # 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: """ - Set the Slack API token. + Set the Slack API token (for backward compatibility). Args: token: Slack API 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. @@ -52,8 +230,7 @@ class SlackHistory: SlackApiError: If there's an unrecoverable error calling the Slack API RuntimeError: For unexpected errors during channel fetching. """ - if not self.client: - raise ValueError("Slack client not initialized. Call set_token() first.") + client = await self._ensure_client() channels_list = [] # Changed from dict to list types = "public_channel" @@ -72,7 +249,7 @@ class SlackHistory: time.sleep(3) current_limit = 1000 # Max limit - api_result = self.client.conversations_list( + api_result = client.conversations_list( types=types, cursor=next_cursor, limit=current_limit ) @@ -129,7 +306,7 @@ class SlackHistory: return channels_list - def get_conversation_history( + async def get_conversation_history( self, channel_id: str, limit: int = 1000, @@ -152,8 +329,7 @@ class SlackHistory: ValueError: If no Slack client has been initialized SlackApiError: If there's an error calling the Slack API """ - if not self.client: - raise ValueError("Slack client not initialized. Call set_token() first.") + client = await self._ensure_client() messages = [] next_cursor = None @@ -177,7 +353,7 @@ class SlackHistory: current_api_call_successful = False result = None # Ensure result is defined try: - result = self.client.conversations_history(**kwargs) + result = client.conversations_history(**kwargs) current_api_call_successful = True except SlackApiError as e_history: if ( @@ -252,7 +428,7 @@ class SlackHistory: except ValueError: 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 ) -> tuple[list[dict[str, Any]], str | None]: """ @@ -282,7 +458,7 @@ class SlackHistory: latest += 86400 # seconds in a day try: - messages = self.get_conversation_history( + messages = await self.get_conversation_history( channel_id=channel_id, limit=limit, oldest=oldest, latest=latest ) return messages, None @@ -291,7 +467,7 @@ class SlackHistory: except ValueError as 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. @@ -305,8 +481,7 @@ class SlackHistory: ValueError: If no Slack client has been initialized SlackApiError: If there's an error calling the Slack API """ - if not self.client: - raise ValueError("Slack client not initialized. Call set_token() first.") + client = await self._ensure_client() while True: try: @@ -314,7 +489,7 @@ class SlackHistory: # For now, we are only adding Retry-After as per plan. # 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 except SlackApiError as e_user_info: @@ -343,7 +518,7 @@ class SlackHistory: ) 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 ) -> dict[str, Any]: """ @@ -369,9 +544,9 @@ class SlackHistory: "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: - 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_email"] = user_info.get("profile", {}).get("email", "") except Exception: diff --git a/surfsense_backend/app/routes/slack_add_connector_route.py b/surfsense_backend/app/routes/slack_add_connector_route.py index 1bbb4f5f1..71a362119 100644 --- a/surfsense_backend/app/routes/slack_add_connector_route.py +++ b/surfsense_backend/app/routes/slack_add_connector_route.py @@ -23,6 +23,7 @@ from app.db import ( 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 @@ -229,7 +230,7 @@ async def slack_callback( ) # Extract bot token from Slack response - # Slack OAuth v2 returns: { "ok": true, "access_token": "...", "bot": { "bot_user_id": "...", "bot_access_token": "xoxb-..." }, ... } + # 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"] @@ -241,6 +242,9 @@ async def slack_callback( 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() @@ -251,9 +255,12 @@ async def slack_callback( now_utc = datetime.now(UTC) expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"])) - # Store the encrypted bot token in connector config + # 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"), @@ -334,3 +341,138 @@ async def slack_callback( 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 diff --git a/surfsense_backend/app/schemas/slack_auth_credentials.py b/surfsense_backend/app/schemas/slack_auth_credentials.py new file mode 100644 index 000000000..ad6a713ef --- /dev/null +++ b/surfsense_backend/app/schemas/slack_auth_credentials.py @@ -0,0 +1,76 @@ +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 + diff --git a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py index 4c4191a4e..c7a815634 100644 --- a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py @@ -17,7 +17,6 @@ from app.utils.document_converters import ( generate_content_hash, generate_unique_identifier_hash, ) -from app.utils.oauth_security import TokenEncryption from .base import ( build_document_metadata_markdown, @@ -93,44 +92,20 @@ async def index_slack_messages( f"Connector with ID {connector_id} not found or is not a Slack connector", ) - # Get the Slack token from the connector config - # Support both new OAuth format (bot_token) and old API format (SLACK_BOT_TOKEN) - config_data = connector.config.copy() - slack_token = config_data.get("bot_token") or config_data.get("SLACK_BOT_TOKEN") + # Note: Token handling is now done automatically by SlackHistory + # 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" - - # Decrypt token if it's encrypted (OAuth format) - token_encrypted = config_data.get("_token_encrypted", False) - if token_encrypted and config.SECRET_KEY: - try: - token_encryption = TokenEncryption(config.SECRET_KEY) - slack_token = token_encryption.decrypt_token(slack_token) - logger.info(f"Decrypted Slack bot token for connector {connector_id}") - except Exception as e: - await task_logger.log_task_failure( - log_entry, - f"Failed to decrypt Slack token for connector {connector_id}: {e!s}", - "Token decryption failed", - {"error_type": "TokenDecryptionError"}, - ) - return 0, f"Failed to decrypt Slack token: {e!s}" - - # Initialize Slack client + # Initialize Slack client with auto-refresh support await task_logger.log_task_progress( log_entry, f"Initializing Slack client for connector {connector_id}", {"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 == "": @@ -167,7 +142,7 @@ async def index_slack_messages( # Get all channels try: - channels = slack_client.get_all_channels() + channels = await slack_client.get_all_channels() except Exception as e: await task_logger.log_task_failure( log_entry, @@ -216,7 +191,7 @@ async def index_slack_messages( continue # 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, start_date=start_date_str, end_date=end_date_str, @@ -249,7 +224,7 @@ async def index_slack_messages( ]: continue - formatted_msg = slack_client.format_message( + formatted_msg = await slack_client.format_message( msg, include_user_info=True ) formatted_messages.append(formatted_msg) From 186273291331b756f5c3dc0da816bb8eeb37905c Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:47:50 +0530 Subject: [PATCH 16/19] refactor: improve error handling for Slack token refresh logic - Updated SlackHistory class to enforce the use of session and connector_id for token refresh, raising a ValueError for legacy token usage. - Simplified conditional checks for client initialization in SlackHistory. - Cleaned up unnecessary comments and whitespace in the codebase. --- .../app/connectors/slack_history.py | 20 +++++++++++-------- .../app/schemas/slack_auth_credentials.py | 1 - .../tasks/connector_indexers/slack_indexer.py | 4 +--- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/surfsense_backend/app/connectors/slack_history.py b/surfsense_backend/app/connectors/slack_history.py index 6a016394e..dbf43bb24 100644 --- a/surfsense_backend/app/connectors/slack_history.py +++ b/surfsense_backend/app/connectors/slack_history.py @@ -65,19 +65,23 @@ class SlackHistory: """ # 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: + 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 - if self._credentials is None: - # 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" - ) + # 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: diff --git a/surfsense_backend/app/schemas/slack_auth_credentials.py b/surfsense_backend/app/schemas/slack_auth_credentials.py index ad6a713ef..5148a0985 100644 --- a/surfsense_backend/app/schemas/slack_auth_credentials.py +++ b/surfsense_backend/app/schemas/slack_auth_credentials.py @@ -73,4 +73,3 @@ class SlackAuthCredentialsBase(BaseModel): if isinstance(v, datetime): return v if v.tzinfo else v.replace(tzinfo=UTC) return v - diff --git a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py index c7a815634..dad64ad27 100644 --- a/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/slack_indexer.py @@ -103,9 +103,7 @@ async def index_slack_messages( ) # Use the new pattern with session and connector_id for auto-refresh - slack_client = SlackHistory( - session=session, connector_id=connector_id - ) + slack_client = SlackHistory(session=session, connector_id=connector_id) # Handle 'undefined' string from frontend (treat as None) if start_date == "undefined" or start_date == "": From df23813f1cfa310be240e906b41a483ab9bc6204 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:21:39 +0530 Subject: [PATCH 17/19] feat: add Discord OAuth integration and connector routes - Introduced Discord OAuth support with new environment variables for client ID, client secret, and redirect URI. - Implemented Discord connector routes for OAuth flow, including authorization and callback handling. - Enhanced Discord connector to support both OAuth-based authentication and legacy bot token usage. - Updated Discord indexing logic to utilize OAuth credentials with auto-refresh capabilities. - Removed outdated Discord UI components and adjusted frontend logic to reflect the new integration. --- surfsense_backend/.env.example | 6 + surfsense_backend/app/config/__init__.py | 6 + .../app/connectors/discord_connector.py | 169 +++++- surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/discord_add_connector_route.py | 509 ++++++++++++++++++ .../app/schemas/discord_auth_credentials.py | 76 +++ .../connector_indexers/discord_indexer.py | 131 +++-- surfsense_backend/app/utils/validators.py | 2 +- .../components/discord-connect-form.tsx | 409 -------------- .../connector-popup/connect-forms/index.tsx | 3 - .../components/discord-config.tsx | 84 +-- .../views/connector-connect-view.tsx | 1 - .../constants/connector-constants.ts | 13 +- 13 files changed, 878 insertions(+), 533 deletions(-) create mode 100644 surfsense_backend/app/routes/discord_add_connector_route.py create mode 100644 surfsense_backend/app/schemas/discord_auth_credentials.py delete mode 100644 surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 2cacedc21..d2c667178 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -44,6 +44,12 @@ AIRTABLE_CLIENT_ID=your_airtable_client_id AIRTABLE_CLIENT_SECRET=your_airtable_client_secret AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback +# 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 diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index f69d1c1a3..f65a94cc0 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -105,6 +105,12 @@ class Config: 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 # Legacy environment variables removed in favor of user-specific configurations diff --git a/surfsense_backend/app/connectors/discord_connector.py b/surfsense_backend/app/connectors/discord_connector.py index 506b463a5..1e12cb9a4 100644 --- a/surfsense_backend/app/connectors/discord_connector.py +++ b/surfsense_backend/app/connectors/discord_connector.py @@ -3,7 +3,7 @@ Discord Connector 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 @@ -12,6 +12,14 @@ import logging import discord 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__) @@ -19,12 +27,21 @@ logger = logging.getLogger(__name__) class DiscordConnector(commands.Bot): """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: - 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.guilds = True # Required to fetch guilds and channels @@ -34,7 +51,14 @@ class DiscordConnector(commands.Bot): super().__init__( command_prefix="!", intents=intents ) # 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._is_running = False # Flag to track if the bot is running @@ -57,12 +81,143 @@ class DiscordConnector(commands.Bot): async def on_resumed(): 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): """Starts the bot to connect to Discord.""" logger.info("Starting Discord bot...") + # Get valid token (with auto-refresh if using OAuth) 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: if self._is_running: @@ -107,7 +262,7 @@ class DiscordConnector(commands.Bot): def set_token(self, token: str) -> None: """ - Set the discord bot token. + Set the discord bot token (for backward compatibility). Args: token (str): The Discord bot token. diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 05020deff..b35d743e0 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -27,6 +27,7 @@ from .rbac_routes import router as rbac_router from .search_source_connectors_routes import router as search_source_connectors_router from .search_spaces_routes import router as search_spaces_router from .slack_add_connector_route import router as slack_add_connector_router +from .discord_add_connector_route import router as discord_add_connector_router router = APIRouter() @@ -46,6 +47,7 @@ router.include_router(linear_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(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks diff --git a/surfsense_backend/app/routes/discord_add_connector_route.py b/surfsense_backend/app/routes/discord_add_connector_route.py new file mode 100644 index 000000000..70a0046a3 --- /dev/null +++ b/surfsense_backend/app/routes/discord_add_connector_route.py @@ -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 + diff --git a/surfsense_backend/app/schemas/discord_auth_credentials.py b/surfsense_backend/app/schemas/discord_auth_credentials.py new file mode 100644 index 000000000..0c18a7554 --- /dev/null +++ b/surfsense_backend/app/schemas/discord_auth_credentials.py @@ -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 + diff --git a/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py b/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py index 9391be788..b3de1f4b5 100644 --- a/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/discord_indexer.py @@ -8,6 +8,7 @@ from datetime import UTC, datetime, timedelta from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession +from app.config import config from app.connectors.discord_connector import DiscordConnector from app.db import Document, DocumentType, SearchSourceConnectorType from app.services.llm_service import get_user_long_context_llm @@ -69,6 +70,12 @@ async def index_discord_messages( ) try: + # Normalize date parameters - handle 'undefined' strings from frontend + if start_date and (start_date.lower() == "undefined" or start_date.strip() == ""): + start_date = None + if end_date and (end_date.lower() == "undefined" or end_date.strip() == ""): + end_date = None + # Get the connector await task_logger.log_task_progress( 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", ) - # 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}") - # Initialize Discord client + # Initialize Discord client with OAuth credentials support await task_logger.log_task_progress( log_entry, f"Initializing Discord client for connector {connector_id}", {"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 if start_date is None or end_date is None: @@ -135,32 +169,63 @@ async def index_discord_messages( if start_date is None: start_date_iso = calculated_start_date.isoformat() 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 = ( datetime.strptime(start_date, "%Y-%m-%d") .replace(tzinfo=UTC) .isoformat() ) - - if end_date is None: - end_date_iso = calculated_end_date.isoformat() - else: - # Convert YYYY-MM-DD to ISO format - end_date_iso = ( - datetime.strptime(end_date, "%Y-%m-%d") - .replace(tzinfo=UTC) - .isoformat() + except ValueError as e: + await task_logger.log_task_failure( + log_entry, + f"Invalid start_date format: {start_date}", + f"Date parsing error: {e!s}", + {"error_type": "InvalidDateFormat", "start_date": start_date}, ) - else: - # Convert provided dates to ISO format for Discord API - start_date_iso = ( - datetime.strptime(start_date, "%Y-%m-%d") - .replace(tzinfo=UTC) - .isoformat() - ) - end_date_iso = ( - datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC).isoformat() - ) + return 0, f"Invalid start_date format: {start_date}. Expected YYYY-MM-DD format." + + try: + end_date_iso = ( + datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=UTC).isoformat() + ) + except ValueError as e: + await task_logger.log_task_failure( + log_entry, + f"Invalid end_date format: {end_date}", + f"Date parsing error: {e!s}", + {"error_type": "InvalidDateFormat", "end_date": end_date}, + ) + return 0, f"Invalid end_date format: {end_date}. Expected YYYY-MM-DD format." logger.info( f"Indexing Discord messages from {start_date_iso} to {end_date_iso}" diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index 8db6ed4a3..f1620c0e5 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -537,7 +537,7 @@ def validate_connector_config( ) }, }, - "DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}}, + # "DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}}, "JIRA_CONNECTOR": { "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], "validators": { diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx deleted file mode 100644 index 8f4fa1a47..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/discord-connect-form.tsx +++ /dev/null @@ -1,409 +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; - -export const DiscordConnectForm: FC = ({ onSubmit, isSubmitting }) => { - const isSubmittingRef = useRef(false); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); - const form = useForm({ - resolver: zodResolver(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 ( -
- - -
- Bot Token Required - - You'll need a Discord Bot Token to use this connector. You can create one from{" "} - - Discord Developer Portal - - -
-
- -
-
- - ( - - Connector Name - - - - - A friendly name to identify this connector. - - - - )} - /> - - ( - - Discord Bot Token - - - - - Your Discord Bot Token will be encrypted and stored securely. - - - - )} - /> - - {/* Indexing Configuration */} -
-

Indexing Configuration

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

Enable Periodic Sync

-

- Automatically re-index at regular intervals -

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

What you get with Discord integration:

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

How it works

-

- The Discord connector uses the Discord API to fetch messages from all accessible - channels that the bot token has access to within a server. -

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

Authorization

- - - Bot Token Required - - You need to create a Discord application and bot to get a bot token. The bot - needs read access to channels and messages. - - - -
-
-

- Step 1: Create a Discord Application -

-
    -
  1. - Go to{" "} - - https://discord.com/developers/applications - -
  2. -
  3. - Click New Application -
  4. -
  5. - Enter an application name and click Create -
  6. -
-
- -
-

- Step 2: Create a Bot -

-
    -
  1. - Navigate to Bot in the sidebar -
  2. -
  3. - Click Add Bot and confirm -
  4. -
  5. - Under Privileged Gateway Intents, enable: -
      -
    • - - MESSAGE CONTENT INTENT - {" "} - - Required to read message content -
    • -
    -
  6. -
-
- -
-

- Step 3: Get Bot Token and Invite Bot -

-
    -
  1. - Under Token, click Reset Token and copy - the token -
  2. -
  3. - Navigate to OAuth2 → URL Generator -
  4. -
  5. - Select bot scope and Read Messages{" "} - permission -
  6. -
  7. Copy the generated URL and open it in your browser
  8. -
  9. Select your server and authorize the bot
  10. -
-
-
-
-
- -
-
-

Indexing

-
    -
  1. - Navigate to the Connector Dashboard and select the Discord{" "} - Connector. -
  2. -
  3. - Place the Bot Token in the form field. -
  4. -
  5. - Click Connect to establish the connection. -
  6. -
  7. Once connected, your Discord messages will be indexed automatically.
  8. -
- - - - What Gets Indexed - -

The Discord connector indexes the following data:

-
    -
  • Messages from all accessible channels
  • -
  • Direct messages (if bot has access)
  • -
  • Message timestamps and metadata
  • -
  • Thread replies and conversations
  • -
-
-
-
-
-
-
-
-
- ); -}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx index 807d4cb7a..81e5ee03f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx @@ -4,7 +4,6 @@ import { BookStackConnectForm } from "./components/bookstack-connect-form"; import { CirclebackConnectForm } from "./components/circleback-connect-form"; import { ClickUpConnectForm } from "./components/clickup-connect-form"; import { ConfluenceConnectForm } from "./components/confluence-connect-form"; -import { DiscordConnectForm } from "./components/discord-connect-form"; import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form"; import { GithubConnectForm } from "./components/github-connect-form"; import { JiraConnectForm } from "./components/jira-connect-form"; @@ -50,8 +49,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo return BaiduSearchApiConnectForm; case "ELASTICSEARCH_CONNECTOR": return ElasticsearchConnectForm; - case "DISCORD_CONNECTOR": - return DiscordConnectForm; case "CONFLUENCE_CONNECTOR": return ConfluenceConnectForm; case "BOOKSTACK_CONNECTOR": diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx index 377987637..464bc438f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/discord-config.tsx @@ -1,88 +1,26 @@ "use client"; -import { KeyRound } from "lucide-react"; +import { Info } 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 DiscordConfigProps extends ConnectorConfigProps { onNameChange?: (name: string) => void; } -export const DiscordConfig: FC = ({ - connector, - onConfigChange, - onNameChange, -}) => { - const [botToken, setBotToken] = useState( - (connector.config?.DISCORD_BOT_TOKEN as string) || "" - ); - const [name, setName] = useState(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); - } - }; - +export const DiscordConfig: FC = () => { return (
- {/* Connector Name */} -
-
- - handleNameChange(e.target.value)} - placeholder="My Discord Connector" - className="border-slate-400/20 focus-visible:border-slate-400/40" - /> -

- A friendly name to identify this connector. -

+
+
+
-
- - {/* Configuration */} -
-
-

Configuration

-
- -
- - handleBotTokenChange(e.target.value)} - placeholder="Your Bot Token" - className="border-slate-400/20 focus-visible:border-slate-400/40" - /> -

- Update your Discord Bot Token if needed. +

+

Add Bot to Servers

+

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

diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx index 5437426c8..3ba03f956 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -52,7 +52,6 @@ export const ConnectorConnectView: FC = ({ LINKUP_API: "linkup-api-connect-form", BAIDU_SEARCH_API: "baidu-search-api-connect-form", ELASTICSEARCH_CONNECTOR: "elasticsearch-connect-form", - DISCORD_CONNECTOR: "discord-connect-form", CONFLUENCE_CONNECTOR: "confluence-connect-form", BOOKSTACK_CONNECTOR: "bookstack-connect-form", GITHUB_CONNECTOR: "github-connect-form", diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 111b7485d..9822ff6e6 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -51,6 +51,13 @@ export const OAUTH_CONNECTORS = [ 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; // Content Sources (tools that extract and import content from external sources) @@ -71,12 +78,6 @@ export const CRAWLERS = [ // Non-OAuth Connectors (redirect to old connector config pages) export const OTHER_CONNECTORS = [ - { - id: "discord-connector", - title: "Discord", - description: "Search Discord messages", - connectorType: EnumConnectorName.DISCORD_CONNECTOR, - }, { id: "confluence-connector", title: "Confluence", From bfed9a31f822929cc2b59b6c78a6ebe58a6c97d8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 5 Jan 2026 23:59:16 +0530 Subject: [PATCH 18/19] 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. - 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. --- surfsense_backend/.env.example | 5 + surfsense_backend/app/config/__init__.py | 5 + .../app/connectors/jira_connector.py | 103 +++- surfsense_backend/app/routes/__init__.py | 2 + .../app/routes/jira_add_connector_route.py | 495 ++++++++++++++++++ .../app/schemas/jira_auth_credentials.py | 73 +++ .../tasks/connector_indexers/jira_indexer.py | 142 ++++- surfsense_backend/app/utils/validators.py | 14 +- .../components/jira-connect-form.tsx | 450 ---------------- .../connector-popup/connect-forms/index.tsx | 3 - .../components/jira-config.tsx | 51 +- .../views/connector-connect-view.tsx | 1 - .../constants/connector-constants.ts | 13 +- .../hooks/use-connector-edit-page.ts | 10 + 14 files changed, 845 insertions(+), 522 deletions(-) create mode 100644 surfsense_backend/app/routes/jira_add_connector_route.py create mode 100644 surfsense_backend/app/schemas/jira_auth_credentials.py delete mode 100644 surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index d2c667178..a2f662c23 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -50,6 +50,11 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret_here DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback DISCORD_BOT_TOKEN=your_bot_token_from_developer_portal +# Jira OAuth Configuration +JIRA_CLIENT_ID=our_jira_client_id +JIRA_CLIENT_SECRET=your_jira_client_secret +JIRA_REDIRECT_URI=http://localhost:8000/api/v1/auth/jira/connector/callback + # OAuth for Linear Connector LINEAR_CLIENT_ID=your_linear_client_id LINEAR_CLIENT_SECRET=your_linear_client_secret diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index f65a94cc0..56641215d 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -111,6 +111,11 @@ class Config: DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI") DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") + # Jira OAuth + JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") + JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") + JIRA_REDIRECT_URI = os.getenv("JIRA_REDIRECT_URI") + # LLM instances are now managed per-user through the LLMConfig system # Legacy environment variables removed in favor of user-specific configurations diff --git a/surfsense_backend/app/connectors/jira_connector.py b/surfsense_backend/app/connectors/jira_connector.py index e73198e79..8e9badf0b 100644 --- a/surfsense_backend/app/connectors/jira_connector.py +++ b/surfsense_backend/app/connectors/jira_connector.py @@ -3,6 +3,7 @@ Jira Connector Module A module for retrieving data from Jira. Allows fetching issue lists and their comments, projects and more. +Supports both OAuth 2.0 (preferred) and legacy API token authentication. """ import base64 @@ -18,6 +19,8 @@ class JiraConnector: def __init__( self, base_url: str | None = None, + access_token: str | None = None, + cloud_id: str | None = None, email: str | None = None, api_token: str | None = None, ): @@ -25,18 +28,39 @@ class JiraConnector: Initialize the JiraConnector class. Args: - base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') (optional) - email: Jira account email address (optional) - api_token: Jira API token (optional) + base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') + access_token: OAuth 2.0 access token (preferred method) + cloud_id: Atlassian cloud ID (used with OAuth for API URL construction) + email: Jira account email address (legacy method, used with api_token) + api_token: Jira API token (legacy method, used with email) """ self.base_url = base_url.rstrip("/") if base_url else None + self.access_token = access_token + self.cloud_id = cloud_id self.email = email self.api_token = api_token self.api_version = "3" # Jira Cloud API version + self._use_oauth = access_token is not None + + def set_oauth_credentials( + self, base_url: str, access_token: str, cloud_id: str | None = None + ) -> None: + """ + Set OAuth 2.0 credentials (preferred method). + + Args: + base_url: Jira instance base URL + access_token: OAuth 2.0 access token + cloud_id: Atlassian cloud ID (optional, used for API URL construction) + """ + self.base_url = base_url.rstrip("/") + self.access_token = access_token + self.cloud_id = cloud_id + self._use_oauth = True def set_credentials(self, base_url: str, email: str, api_token: str) -> None: """ - Set the Jira credentials. + Set the Jira credentials (legacy method using API token). Args: base_url: Jira instance base URL @@ -46,50 +70,69 @@ class JiraConnector: self.base_url = base_url.rstrip("/") self.email = email self.api_token = api_token + self._use_oauth = False def set_email(self, email: str) -> None: """ - Set the Jira account email. + Set the Jira account email (legacy method). Args: email: Jira account email address """ self.email = email + self._use_oauth = False def set_api_token(self, api_token: str) -> None: """ - Set the Jira API token. + Set the Jira API token (legacy method). Args: api_token: Jira API token """ self.api_token = api_token + self._use_oauth = False def get_headers(self) -> dict[str, str]: """ - Get headers for Jira API requests using Basic Authentication. + Get headers for Jira API requests. + + Uses OAuth Bearer token if available, otherwise falls back to Basic Auth. Returns: Dictionary of headers Raises: - ValueError: If email, api_token, or base_url have not been set + ValueError: If credentials have not been set """ - if not all([self.base_url, self.email, self.api_token]): - raise ValueError( - "Jira credentials not initialized. Call set_credentials() first." - ) + if self._use_oauth: + # OAuth 2.0 authentication + if not self.base_url or not self.access_token: + raise ValueError( + "Jira OAuth credentials not initialized. Call set_oauth_credentials() first." + ) - # Create Basic Auth header using email:api_token - auth_str = f"{self.email}:{self.api_token}" - auth_bytes = auth_str.encode("utf-8") - auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii") + return { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.access_token}", + "Accept": "application/json", + } + else: + # Legacy Basic Auth + if not all([self.base_url, self.email, self.api_token]): + raise ValueError( + "Jira credentials not initialized. Call set_credentials() first." + ) - return { - "Content-Type": "application/json", - "Authorization": auth_header, - "Accept": "application/json", - } + # Create Basic Auth header using email:api_token + auth_str = f"{self.email}:{self.api_token}" + auth_bytes = auth_str.encode("utf-8") + auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii") + + return { + "Content-Type": "application/json", + "Authorization": auth_header, + "Accept": "application/json", + } def make_api_request( self, @@ -104,22 +147,26 @@ class JiraConnector: Args: endpoint: API endpoint (without base URL) params: Query parameters for the request (optional) + method: HTTP method (GET or POST) + json_payload: JSON payload for POST requests (optional) Returns: Response data from the API Raises: - ValueError: If email, api_token, or base_url have not been set + ValueError: If credentials have not been set Exception: If the API request fails """ - if not all([self.base_url, self.email, self.api_token]): - raise ValueError( - "Jira credentials not initialized. Call set_credentials() first." - ) - - url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}" headers = self.get_headers() + # Construct API URL based on authentication method + if self._use_oauth and self.cloud_id: + # Use Atlassian API gateway with cloud_id for OAuth + url = f"https://api.atlassian.com/ex/jira/{self.cloud_id}/rest/api/{self.api_version}/{endpoint}" + else: + # Use direct base URL (works for both OAuth and legacy) + url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}" + if method.upper() == "POST": response = requests.post( url, headers=headers, json=json_payload, timeout=500 diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index b35d743e0..16cacfeb8 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -28,6 +28,7 @@ from .search_source_connectors_routes import router as search_source_connectors_ 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 +from .jira_add_connector_route import router as jira_add_connector_router router = APIRouter() @@ -48,6 +49,7 @@ router.include_router(luma_add_connector_router) router.include_router(notion_add_connector_router) router.include_router(slack_add_connector_router) router.include_router(discord_add_connector_router) +router.include_router(jira_add_connector_router) router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py new file mode 100644 index 000000000..ac415e80e --- /dev/null +++ b/surfsense_backend/app/routes/jira_add_connector_route.py @@ -0,0 +1,495 @@ +""" +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 + diff --git a/surfsense_backend/app/schemas/jira_auth_credentials.py b/surfsense_backend/app/schemas/jira_auth_credentials.py new file mode 100644 index 000000000..23d1ffcbf --- /dev/null +++ b/surfsense_backend/app/schemas/jira_auth_credentials.py @@ -0,0 +1,73 @@ +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 + diff --git a/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py b/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py index 8c56b10ab..616927e6f 100644 --- a/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py @@ -84,31 +84,137 @@ async def index_jira_issues( return 0, f"Connector with ID {connector_id} not found" # Get the Jira credentials from the connector config - jira_email = connector.config.get("JIRA_EMAIL") - jira_api_token = connector.config.get("JIRA_API_TOKEN") - jira_base_url = connector.config.get("JIRA_BASE_URL") + # Support both OAuth (preferred) and legacy API token authentication + config_data = connector.config.copy() + 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: - await task_logger.log_task_failure( + if is_oauth: + # 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, - f"Jira credentials not found in connector config for connector {connector_id}", - "Missing Jira credentials", - {"error_type": "MissingCredentials"}, + f"Initializing Jira client with OAuth for connector {connector_id}", + {"stage": "client_initialization"}, ) - return 0, "Jira credentials not found in connector config" - # Initialize Jira client - await task_logger.log_task_progress( - log_entry, - f"Initializing Jira client for connector {connector_id}", - {"stage": "client_initialization"}, - ) + jira_client = JiraConnector( + base_url=credentials.base_url, + access_token=credentials.access_token, + cloud_id=credentials.cloud_id, + ) + 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( - base_url=jira_base_url, email=jira_email, api_token=jira_api_token - ) + if not jira_email or not jira_api_token or not jira_base_url: + await task_logger.log_task_failure( + log_entry, + f"Jira credentials not found in connector config for connector {connector_id}", + "Missing Jira credentials", + {"error_type": "MissingCredentials"}, + ) + return 0, "Jira credentials not found in connector config" + + # Initialize Jira client 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 + # Handle "undefined" strings from frontend + if start_date == "undefined" or start_date == "": + start_date = None + if end_date == "undefined" or end_date == "": + end_date = None + start_date_str, end_date_str = calculate_date_range( connector, start_date, end_date, default_days_back=365 ) diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index f1620c0e5..d1f416339 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -538,13 +538,13 @@ def validate_connector_config( }, }, # "DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}}, - "JIRA_CONNECTOR": { - "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], - "validators": { - "JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"), - "JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"), - }, - }, + # "JIRA_CONNECTOR": { + # "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], + # "validators": { + # "JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"), + # "JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"), + # }, + # }, "CONFLUENCE_CONNECTOR": { "required": [ "CONFLUENCE_BASE_URL", diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx deleted file mode 100644 index 0499554b4..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx +++ /dev/null @@ -1,450 +0,0 @@ -"use client"; - -import { zodResolver } from "@hookform/resolvers/zod"; -import { Info } from "lucide-react"; -import type { FC } from "react"; -import { useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Switch } from "@/components/ui/switch"; -import { EnumConnectorName } from "@/contracts/enums/connector"; -import { DateRangeSelector } from "../../components/date-range-selector"; -import { getConnectorBenefits } from "../connector-benefits"; -import type { ConnectFormProps } from "../index"; - -const jiraConnectorFormSchema = z.object({ - name: z.string().min(3, { - message: "Connector name must be at least 3 characters.", - }), - base_url: z.string().url({ message: "Please enter a valid Jira base URL." }), - email: z.string().email({ message: "Please enter a valid email address." }), - api_token: z.string().min(10, { - message: "Jira API Token is required and must be valid.", - }), -}); - -type JiraConnectorFormValues = z.infer; - -export const JiraConnectForm: FC = ({ onSubmit, isSubmitting }) => { - const isSubmittingRef = useRef(false); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); - const form = useForm({ - resolver: zodResolver(jiraConnectorFormSchema), - defaultValues: { - name: "Jira Connector", - base_url: "", - email: "", - api_token: "", - }, - }); - - const handleSubmit = async (values: JiraConnectorFormValues) => { - // Prevent multiple submissions - if (isSubmittingRef.current || isSubmitting) { - return; - } - - isSubmittingRef.current = true; - try { - await onSubmit({ - name: values.name, - connector_type: EnumConnectorName.JIRA_CONNECTOR, - config: { - JIRA_BASE_URL: values.base_url, - JIRA_EMAIL: values.email, - JIRA_API_TOKEN: values.api_token, - }, - is_indexable: true, - last_indexed_at: null, - periodic_indexing_enabled: periodicEnabled, - indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, - next_scheduled_at: null, - startDate, - endDate, - periodicEnabled, - frequencyMinutes, - }); - } finally { - isSubmittingRef.current = false; - } - }; - - return ( -
- - -
- API Token Required - - You'll need a Jira API Token to use this connector. You can create one from{" "} - - Atlassian Account Settings - - -
-
- -
-
- - ( - - Connector Name - - - - - A friendly name to identify this connector. - - - - )} - /> - - ( - - Jira Base URL - - - - - The base URL of your Jira instance (e.g., https://your-domain.atlassian.net). - - - - )} - /> - - ( - - Email Address - - - - - The email address associated with your Atlassian account. - - - - )} - /> - - ( - - API Token - - - - - Your Jira API Token will be encrypted and stored securely. - - - - )} - /> - - {/* Indexing Configuration */} -
-

Indexing Configuration

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

Enable Periodic Sync

-

- Automatically re-index at regular intervals -

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

What you get with Jira integration:

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

How it works

-

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

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

Authorization

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

- Step 1: Create an API Token -

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

- Step 2: Grant necessary access -

-

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

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

Indexing

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

The Jira connector indexes the following data:

-
    -
  • Issue keys and summaries (e.g., PROJ-123)
  • -
  • Issue descriptions
  • -
  • Issue comments and discussion threads
  • -
  • Issue status, priority, and type information
  • -
  • Assignee and reporter information
  • -
  • Project information
  • -
-
-
-
-
-
-
-
-
- ); -}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx index 81e5ee03f..cda17ddfc 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx @@ -6,7 +6,6 @@ import { ClickUpConnectForm } from "./components/clickup-connect-form"; import { ConfluenceConnectForm } from "./components/confluence-connect-form"; import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form"; import { GithubConnectForm } from "./components/github-connect-form"; -import { JiraConnectForm } from "./components/jira-connect-form"; import { LinkupApiConnectForm } from "./components/linkup-api-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form"; import { SearxngConnectForm } from "./components/searxng-connect-form"; @@ -55,8 +54,6 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo return BookStackConnectForm; case "GITHUB_CONNECTOR": return GithubConnectForm; - case "JIRA_CONNECTOR": - return JiraConnectForm; case "CLICKUP_CONNECTOR": return ClickUpConnectForm; case "LUMA_CONNECTOR": diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx index 3ef16bdb4..158dfdf13 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx @@ -1,6 +1,6 @@ "use client"; -import { KeyRound } from "lucide-react"; +import { Info, KeyRound } from "lucide-react"; import type { FC } from "react"; import { useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; @@ -12,6 +12,9 @@ export interface JiraConfigProps extends ConnectorConfigProps { } export const JiraConfig: FC = ({ connector, onConfigChange, onNameChange }) => { + // Check if this is an OAuth connector (has access_token or _token_encrypted flag) + const isOAuth = !!(connector.config?.access_token || connector.config?._token_encrypted); + const [baseUrl, setBaseUrl] = useState((connector.config?.JIRA_BASE_URL as string) || ""); const [email, setEmail] = useState((connector.config?.JIRA_EMAIL as string) || ""); const [apiToken, setApiToken] = useState( @@ -19,16 +22,18 @@ export const JiraConfig: FC = ({ connector, onConfigChange, onN ); const [name, setName] = useState(connector.name || ""); - // Update values when connector changes + // Update values when connector changes (only for legacy connectors) useEffect(() => { - const url = (connector.config?.JIRA_BASE_URL as string) || ""; - const emailVal = (connector.config?.JIRA_EMAIL as string) || ""; - const token = (connector.config?.JIRA_API_TOKEN as string) || ""; - setBaseUrl(url); - setEmail(emailVal); - setApiToken(token); + if (!isOAuth) { + const url = (connector.config?.JIRA_BASE_URL as string) || ""; + const emailVal = (connector.config?.JIRA_EMAIL as string) || ""; + const token = (connector.config?.JIRA_API_TOKEN as string) || ""; + setBaseUrl(url); + setEmail(emailVal); + setApiToken(token); + } setName(connector.name || ""); - }, [connector.config, connector.name]); + }, [connector.config, connector.name, isOAuth]); const handleBaseUrlChange = (value: string) => { setBaseUrl(value); @@ -67,6 +72,34 @@ export const JiraConfig: FC = ({ connector, onConfigChange, onN } }; + // For OAuth connectors, show simple info message + if (isOAuth) { + const baseUrl = (connector.config?.base_url as string) || "Unknown"; + return ( +
+ {/* OAuth Info */} +
+
+ +
+
+

Connected via OAuth

+

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

+

+ {baseUrl} +

+

+ To update your connection, disconnect and reconnect through the OAuth flow. +

+
+
+
+ ); + } + + // For legacy API token connectors, show the form return (
{/* Connector Name */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx index 3ba03f956..7b0c3e82f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -55,7 +55,6 @@ export const ConnectorConnectView: FC = ({ CONFLUENCE_CONNECTOR: "confluence-connect-form", BOOKSTACK_CONNECTOR: "bookstack-connect-form", GITHUB_CONNECTOR: "github-connect-form", - JIRA_CONNECTOR: "jira-connect-form", CLICKUP_CONNECTOR: "clickup-connect-form", LUMA_CONNECTOR: "luma-connect-form", CIRCLEBACK_CONNECTOR: "circleback-connect-form", diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 9822ff6e6..0e942dd1e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -58,6 +58,13 @@ export const OAUTH_CONNECTORS = [ connectorType: EnumConnectorName.DISCORD_CONNECTOR, authEndpoint: "/api/v1/auth/discord/connector/add/", }, + { + id: "jira-connector", + title: "Jira", + description: "Search Jira issues", + connectorType: EnumConnectorName.JIRA_CONNECTOR, + authEndpoint: "/api/v1/auth/jira/connector/add/", + }, ] as const; // Content Sources (tools that extract and import content from external sources) @@ -96,12 +103,6 @@ export const OTHER_CONNECTORS = [ description: "Search repositories", connectorType: EnumConnectorName.GITHUB_CONNECTOR, }, - { - id: "jira-connector", - title: "Jira", - description: "Search Jira issues", - connectorType: EnumConnectorName.JIRA_CONNECTOR, - }, { id: "clickup-connector", title: "ClickUp", diff --git a/surfsense_web/hooks/use-connector-edit-page.ts b/surfsense_web/hooks/use-connector-edit-page.ts index 3beb80247..ba4ba6b58 100644 --- a/surfsense_web/hooks/use-connector-edit-page.ts +++ b/surfsense_web/hooks/use-connector-edit-page.ts @@ -447,6 +447,16 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) } break; case "JIRA_CONNECTOR": + // Check if this is an OAuth connector (has access_token or _token_encrypted flag) + const isJiraOAuth = !!(originalConfig.access_token || originalConfig._token_encrypted); + + if (isJiraOAuth) { + // OAuth connectors don't allow editing credentials through the form + // Only allow name changes, which are handled separately + break; + } + + // Legacy API token connector - allow editing credentials if ( formData.JIRA_BASE_URL !== originalConfig.JIRA_BASE_URL || formData.JIRA_EMAIL !== originalConfig.JIRA_EMAIL || From f236110a08176986667c8dd1580a9301604c489e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 6 Jan 2026 00:09:08 +0530 Subject: [PATCH 19/19] Revert "feat: implement Jira OAuth integration and connector routes" This reverts commit bfed9a31f822929cc2b59b6c78a6ebe58a6c97d8. --- surfsense_backend/.env.example | 5 - surfsense_backend/app/config/__init__.py | 5 - .../app/connectors/jira_connector.py | 103 +--- surfsense_backend/app/routes/__init__.py | 2 - .../app/routes/jira_add_connector_route.py | 495 ------------------ .../app/schemas/jira_auth_credentials.py | 73 --- .../tasks/connector_indexers/jira_indexer.py | 142 +---- surfsense_backend/app/utils/validators.py | 14 +- .../components/jira-connect-form.tsx | 450 ++++++++++++++++ .../connector-popup/connect-forms/index.tsx | 3 + .../components/jira-config.tsx | 51 +- .../views/connector-connect-view.tsx | 1 + .../constants/connector-constants.ts | 13 +- .../hooks/use-connector-edit-page.ts | 10 - 14 files changed, 522 insertions(+), 845 deletions(-) delete mode 100644 surfsense_backend/app/routes/jira_add_connector_route.py delete mode 100644 surfsense_backend/app/schemas/jira_auth_credentials.py create mode 100644 surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index a2f662c23..d2c667178 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -50,11 +50,6 @@ DISCORD_CLIENT_SECRET=your_discord_client_secret_here DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback DISCORD_BOT_TOKEN=your_bot_token_from_developer_portal -# Jira OAuth Configuration -JIRA_CLIENT_ID=our_jira_client_id -JIRA_CLIENT_SECRET=your_jira_client_secret -JIRA_REDIRECT_URI=http://localhost:8000/api/v1/auth/jira/connector/callback - # OAuth for Linear Connector LINEAR_CLIENT_ID=your_linear_client_id LINEAR_CLIENT_SECRET=your_linear_client_secret diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 56641215d..f65a94cc0 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -111,11 +111,6 @@ class Config: DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI") DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") - # Jira OAuth - JIRA_CLIENT_ID = os.getenv("JIRA_CLIENT_ID") - JIRA_CLIENT_SECRET = os.getenv("JIRA_CLIENT_SECRET") - JIRA_REDIRECT_URI = os.getenv("JIRA_REDIRECT_URI") - # LLM instances are now managed per-user through the LLMConfig system # Legacy environment variables removed in favor of user-specific configurations diff --git a/surfsense_backend/app/connectors/jira_connector.py b/surfsense_backend/app/connectors/jira_connector.py index 8e9badf0b..e73198e79 100644 --- a/surfsense_backend/app/connectors/jira_connector.py +++ b/surfsense_backend/app/connectors/jira_connector.py @@ -3,7 +3,6 @@ Jira Connector Module A module for retrieving data from Jira. Allows fetching issue lists and their comments, projects and more. -Supports both OAuth 2.0 (preferred) and legacy API token authentication. """ import base64 @@ -19,8 +18,6 @@ class JiraConnector: def __init__( self, base_url: str | None = None, - access_token: str | None = None, - cloud_id: str | None = None, email: str | None = None, api_token: str | None = None, ): @@ -28,39 +25,18 @@ class JiraConnector: Initialize the JiraConnector class. Args: - base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') - access_token: OAuth 2.0 access token (preferred method) - cloud_id: Atlassian cloud ID (used with OAuth for API URL construction) - email: Jira account email address (legacy method, used with api_token) - api_token: Jira API token (legacy method, used with email) + base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') (optional) + email: Jira account email address (optional) + api_token: Jira API token (optional) """ self.base_url = base_url.rstrip("/") if base_url else None - self.access_token = access_token - self.cloud_id = cloud_id self.email = email self.api_token = api_token self.api_version = "3" # Jira Cloud API version - self._use_oauth = access_token is not None - - def set_oauth_credentials( - self, base_url: str, access_token: str, cloud_id: str | None = None - ) -> None: - """ - Set OAuth 2.0 credentials (preferred method). - - Args: - base_url: Jira instance base URL - access_token: OAuth 2.0 access token - cloud_id: Atlassian cloud ID (optional, used for API URL construction) - """ - self.base_url = base_url.rstrip("/") - self.access_token = access_token - self.cloud_id = cloud_id - self._use_oauth = True def set_credentials(self, base_url: str, email: str, api_token: str) -> None: """ - Set the Jira credentials (legacy method using API token). + Set the Jira credentials. Args: base_url: Jira instance base URL @@ -70,69 +46,50 @@ class JiraConnector: self.base_url = base_url.rstrip("/") self.email = email self.api_token = api_token - self._use_oauth = False def set_email(self, email: str) -> None: """ - Set the Jira account email (legacy method). + Set the Jira account email. Args: email: Jira account email address """ self.email = email - self._use_oauth = False def set_api_token(self, api_token: str) -> None: """ - Set the Jira API token (legacy method). + Set the Jira API token. Args: api_token: Jira API token """ self.api_token = api_token - self._use_oauth = False def get_headers(self) -> dict[str, str]: """ - Get headers for Jira API requests. - - Uses OAuth Bearer token if available, otherwise falls back to Basic Auth. + Get headers for Jira API requests using Basic Authentication. Returns: Dictionary of headers Raises: - ValueError: If credentials have not been set + ValueError: If email, api_token, or base_url have not been set """ - if self._use_oauth: - # OAuth 2.0 authentication - if not self.base_url or not self.access_token: - raise ValueError( - "Jira OAuth credentials not initialized. Call set_oauth_credentials() first." - ) + if not all([self.base_url, self.email, self.api_token]): + raise ValueError( + "Jira credentials not initialized. Call set_credentials() first." + ) - return { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.access_token}", - "Accept": "application/json", - } - else: - # Legacy Basic Auth - if not all([self.base_url, self.email, self.api_token]): - raise ValueError( - "Jira credentials not initialized. Call set_credentials() first." - ) + # Create Basic Auth header using email:api_token + auth_str = f"{self.email}:{self.api_token}" + auth_bytes = auth_str.encode("utf-8") + auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii") - # Create Basic Auth header using email:api_token - auth_str = f"{self.email}:{self.api_token}" - auth_bytes = auth_str.encode("utf-8") - auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii") - - return { - "Content-Type": "application/json", - "Authorization": auth_header, - "Accept": "application/json", - } + return { + "Content-Type": "application/json", + "Authorization": auth_header, + "Accept": "application/json", + } def make_api_request( self, @@ -147,25 +104,21 @@ class JiraConnector: Args: endpoint: API endpoint (without base URL) params: Query parameters for the request (optional) - method: HTTP method (GET or POST) - json_payload: JSON payload for POST requests (optional) Returns: Response data from the API Raises: - ValueError: If credentials have not been set + ValueError: If email, api_token, or base_url have not been set Exception: If the API request fails """ - headers = self.get_headers() + if not all([self.base_url, self.email, self.api_token]): + raise ValueError( + "Jira credentials not initialized. Call set_credentials() first." + ) - # 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}" + url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}" + headers = self.get_headers() if method.upper() == "POST": response = requests.post( diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 16cacfeb8..b35d743e0 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -28,7 +28,6 @@ from .search_source_connectors_routes import router as search_source_connectors_ 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 -from .jira_add_connector_route import router as jira_add_connector_router router = APIRouter() @@ -49,7 +48,6 @@ router.include_router(luma_add_connector_router) router.include_router(notion_add_connector_router) router.include_router(slack_add_connector_router) router.include_router(discord_add_connector_router) -router.include_router(jira_add_connector_router) router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(logs_router) router.include_router(circleback_webhook_router) # Circleback meeting webhooks diff --git a/surfsense_backend/app/routes/jira_add_connector_route.py b/surfsense_backend/app/routes/jira_add_connector_route.py deleted file mode 100644 index ac415e80e..000000000 --- a/surfsense_backend/app/routes/jira_add_connector_route.py +++ /dev/null @@ -1,495 +0,0 @@ -""" -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 - diff --git a/surfsense_backend/app/schemas/jira_auth_credentials.py b/surfsense_backend/app/schemas/jira_auth_credentials.py deleted file mode 100644 index 23d1ffcbf..000000000 --- a/surfsense_backend/app/schemas/jira_auth_credentials.py +++ /dev/null @@ -1,73 +0,0 @@ -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 - diff --git a/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py b/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py index 616927e6f..8c56b10ab 100644 --- a/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/jira_indexer.py @@ -84,137 +84,31 @@ async def index_jira_issues( return 0, f"Connector with ID {connector_id} not found" # Get the Jira credentials from the connector config - # Support both OAuth (preferred) and legacy API token authentication - config_data = connector.config.copy() - is_oauth = config_data.get("_token_encrypted", False) or config_data.get("access_token") + jira_email = connector.config.get("JIRA_EMAIL") + jira_api_token = connector.config.get("JIRA_API_TOKEN") + jira_base_url = connector.config.get("JIRA_BASE_URL") - if is_oauth: - # 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( + if not jira_email or not jira_api_token or not jira_base_url: + await task_logger.log_task_failure( log_entry, - f"Initializing Jira client with OAuth for connector {connector_id}", - {"stage": "client_initialization"}, + 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" - jira_client = JiraConnector( - base_url=credentials.base_url, - access_token=credentials.access_token, - cloud_id=credentials.cloud_id, - ) - 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") + # Initialize Jira client + await task_logger.log_task_progress( + log_entry, + f"Initializing Jira client for connector {connector_id}", + {"stage": "client_initialization"}, + ) - if not jira_email or not jira_api_token or not jira_base_url: - await task_logger.log_task_failure( - log_entry, - f"Jira credentials not found in connector config for connector {connector_id}", - "Missing Jira credentials", - {"error_type": "MissingCredentials"}, - ) - return 0, "Jira credentials not found in connector config" - - # Initialize Jira client 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 - ) + jira_client = JiraConnector( + base_url=jira_base_url, email=jira_email, api_token=jira_api_token + ) # Calculate date range - # Handle "undefined" strings from frontend - if start_date == "undefined" or start_date == "": - start_date = None - if end_date == "undefined" or end_date == "": - end_date = None - start_date_str, end_date_str = calculate_date_range( connector, start_date, end_date, default_days_back=365 ) diff --git a/surfsense_backend/app/utils/validators.py b/surfsense_backend/app/utils/validators.py index d1f416339..f1620c0e5 100644 --- a/surfsense_backend/app/utils/validators.py +++ b/surfsense_backend/app/utils/validators.py @@ -538,13 +538,13 @@ def validate_connector_config( }, }, # "DISCORD_CONNECTOR": {"required": ["DISCORD_BOT_TOKEN"], "validators": {}}, - # "JIRA_CONNECTOR": { - # "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], - # "validators": { - # "JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"), - # "JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"), - # }, - # }, + "JIRA_CONNECTOR": { + "required": ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"], + "validators": { + "JIRA_EMAIL": lambda: validate_email_field("JIRA_EMAIL", "JIRA"), + "JIRA_BASE_URL": lambda: validate_url_field("JIRA_BASE_URL", "JIRA"), + }, + }, "CONFLUENCE_CONNECTOR": { "required": [ "CONFLUENCE_BASE_URL", diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx new file mode 100644 index 000000000..0499554b4 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/components/jira-connect-form.tsx @@ -0,0 +1,450 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Info } from "lucide-react"; +import type { FC } from "react"; +import { useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { EnumConnectorName } from "@/contracts/enums/connector"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { getConnectorBenefits } from "../connector-benefits"; +import type { ConnectFormProps } from "../index"; + +const jiraConnectorFormSchema = z.object({ + name: z.string().min(3, { + message: "Connector name must be at least 3 characters.", + }), + base_url: z.string().url({ message: "Please enter a valid Jira base URL." }), + email: z.string().email({ message: "Please enter a valid email address." }), + api_token: z.string().min(10, { + message: "Jira API Token is required and must be valid.", + }), +}); + +type JiraConnectorFormValues = z.infer; + +export const JiraConnectForm: FC = ({ onSubmit, isSubmitting }) => { + const isSubmittingRef = useRef(false); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); + const form = useForm({ + resolver: zodResolver(jiraConnectorFormSchema), + defaultValues: { + name: "Jira Connector", + base_url: "", + email: "", + api_token: "", + }, + }); + + const handleSubmit = async (values: JiraConnectorFormValues) => { + // Prevent multiple submissions + if (isSubmittingRef.current || isSubmitting) { + return; + } + + isSubmittingRef.current = true; + try { + await onSubmit({ + name: values.name, + connector_type: EnumConnectorName.JIRA_CONNECTOR, + config: { + JIRA_BASE_URL: values.base_url, + JIRA_EMAIL: values.email, + JIRA_API_TOKEN: values.api_token, + }, + is_indexable: true, + last_indexed_at: null, + periodic_indexing_enabled: periodicEnabled, + indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, + next_scheduled_at: null, + startDate, + endDate, + periodicEnabled, + frequencyMinutes, + }); + } finally { + isSubmittingRef.current = false; + } + }; + + return ( +
+ + +
+ API Token Required + + You'll need a Jira API Token to use this connector. You can create one from{" "} + + Atlassian Account Settings + + +
+
+ +
+
+ + ( + + Connector Name + + + + + A friendly name to identify this connector. + + + + )} + /> + + ( + + Jira Base URL + + + + + The base URL of your Jira instance (e.g., https://your-domain.atlassian.net). + + + + )} + /> + + ( + + Email Address + + + + + The email address associated with your Atlassian account. + + + + )} + /> + + ( + + API Token + + + + + Your Jira API Token will be encrypted and stored securely. + + + + )} + /> + + {/* Indexing Configuration */} +
+

Indexing Configuration

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

Enable Periodic Sync

+

+ Automatically re-index at regular intervals +

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

What you get with Jira integration:

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

How it works

+

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

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

Authorization

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

+ Step 1: Create an API Token +

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

+ Step 2: Grant necessary access +

+

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

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

Indexing

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

The Jira connector indexes the following data:

+
    +
  • Issue keys and summaries (e.g., PROJ-123)
  • +
  • Issue descriptions
  • +
  • Issue comments and discussion threads
  • +
  • Issue status, priority, and type information
  • +
  • Assignee and reporter information
  • +
  • Project information
  • +
+
+
+
+
+
+
+
+
+ ); +}; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx index cda17ddfc..81e5ee03f 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connect-forms/index.tsx @@ -6,6 +6,7 @@ import { ClickUpConnectForm } from "./components/clickup-connect-form"; import { ConfluenceConnectForm } from "./components/confluence-connect-form"; import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form"; import { GithubConnectForm } from "./components/github-connect-form"; +import { JiraConnectForm } from "./components/jira-connect-form"; import { LinkupApiConnectForm } from "./components/linkup-api-connect-form"; import { LumaConnectForm } from "./components/luma-connect-form"; import { SearxngConnectForm } from "./components/searxng-connect-form"; @@ -54,6 +55,8 @@ export function getConnectFormComponent(connectorType: string): ConnectFormCompo return BookStackConnectForm; case "GITHUB_CONNECTOR": return GithubConnectForm; + case "JIRA_CONNECTOR": + return JiraConnectForm; case "CLICKUP_CONNECTOR": return ClickUpConnectForm; case "LUMA_CONNECTOR": diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx index 158dfdf13..3ef16bdb4 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/jira-config.tsx @@ -1,6 +1,6 @@ "use client"; -import { Info, KeyRound } from "lucide-react"; +import { KeyRound } from "lucide-react"; import type { FC } from "react"; import { useEffect, useState } from "react"; import { Input } from "@/components/ui/input"; @@ -12,9 +12,6 @@ export interface JiraConfigProps extends ConnectorConfigProps { } export const JiraConfig: FC = ({ connector, onConfigChange, onNameChange }) => { - // Check if this is an OAuth connector (has access_token or _token_encrypted flag) - const isOAuth = !!(connector.config?.access_token || connector.config?._token_encrypted); - const [baseUrl, setBaseUrl] = useState((connector.config?.JIRA_BASE_URL as string) || ""); const [email, setEmail] = useState((connector.config?.JIRA_EMAIL as string) || ""); const [apiToken, setApiToken] = useState( @@ -22,18 +19,16 @@ export const JiraConfig: FC = ({ connector, onConfigChange, onN ); const [name, setName] = useState(connector.name || ""); - // Update values when connector changes (only for legacy connectors) + // Update values when connector changes useEffect(() => { - if (!isOAuth) { - const url = (connector.config?.JIRA_BASE_URL as string) || ""; - const emailVal = (connector.config?.JIRA_EMAIL as string) || ""; - const token = (connector.config?.JIRA_API_TOKEN as string) || ""; - setBaseUrl(url); - setEmail(emailVal); - setApiToken(token); - } + const url = (connector.config?.JIRA_BASE_URL as string) || ""; + const emailVal = (connector.config?.JIRA_EMAIL as string) || ""; + const token = (connector.config?.JIRA_API_TOKEN as string) || ""; + setBaseUrl(url); + setEmail(emailVal); + setApiToken(token); setName(connector.name || ""); - }, [connector.config, connector.name, isOAuth]); + }, [connector.config, connector.name]); const handleBaseUrlChange = (value: string) => { setBaseUrl(value); @@ -72,34 +67,6 @@ export const JiraConfig: FC = ({ connector, onConfigChange, onN } }; - // For OAuth connectors, show simple info message - if (isOAuth) { - const baseUrl = (connector.config?.base_url as string) || "Unknown"; - return ( -
- {/* OAuth Info */} -
-
- -
-
-

Connected via OAuth

-

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

-

- {baseUrl} -

-

- To update your connection, disconnect and reconnect through the OAuth flow. -

-
-
-
- ); - } - - // For legacy API token connectors, show the form return (
{/* Connector Name */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx index 7b0c3e82f..3ba03f956 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-connect-view.tsx @@ -55,6 +55,7 @@ export const ConnectorConnectView: FC = ({ CONFLUENCE_CONNECTOR: "confluence-connect-form", BOOKSTACK_CONNECTOR: "bookstack-connect-form", GITHUB_CONNECTOR: "github-connect-form", + JIRA_CONNECTOR: "jira-connect-form", CLICKUP_CONNECTOR: "clickup-connect-form", LUMA_CONNECTOR: "luma-connect-form", CIRCLEBACK_CONNECTOR: "circleback-connect-form", diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 0e942dd1e..9822ff6e6 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -58,13 +58,6 @@ export const OAUTH_CONNECTORS = [ connectorType: EnumConnectorName.DISCORD_CONNECTOR, authEndpoint: "/api/v1/auth/discord/connector/add/", }, - { - id: "jira-connector", - title: "Jira", - description: "Search Jira issues", - connectorType: EnumConnectorName.JIRA_CONNECTOR, - authEndpoint: "/api/v1/auth/jira/connector/add/", - }, ] as const; // Content Sources (tools that extract and import content from external sources) @@ -103,6 +96,12 @@ export const OTHER_CONNECTORS = [ description: "Search repositories", connectorType: EnumConnectorName.GITHUB_CONNECTOR, }, + { + id: "jira-connector", + title: "Jira", + description: "Search Jira issues", + connectorType: EnumConnectorName.JIRA_CONNECTOR, + }, { id: "clickup-connector", title: "ClickUp", diff --git a/surfsense_web/hooks/use-connector-edit-page.ts b/surfsense_web/hooks/use-connector-edit-page.ts index ba4ba6b58..3beb80247 100644 --- a/surfsense_web/hooks/use-connector-edit-page.ts +++ b/surfsense_web/hooks/use-connector-edit-page.ts @@ -447,16 +447,6 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string) } break; case "JIRA_CONNECTOR": - // Check if this is an OAuth connector (has access_token or _token_encrypted flag) - const isJiraOAuth = !!(originalConfig.access_token || originalConfig._token_encrypted); - - if (isJiraOAuth) { - // OAuth connectors don't allow editing credentials through the form - // Only allow name changes, which are handled separately - break; - } - - // Legacy API token connector - allow editing credentials if ( formData.JIRA_BASE_URL !== originalConfig.JIRA_BASE_URL || formData.JIRA_EMAIL !== originalConfig.JIRA_EMAIL ||