From f552a3186f9db4933875b364fc666561f395d4e7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 16:06:09 +0200 Subject: [PATCH 01/10] auto-index connectors after OAuth without config step --- .../constants/connector-constants.ts | 17 ++++ .../hooks/use-connector-dialog.ts | 96 +++++++++++++++---- 2 files changed, 96 insertions(+), 17 deletions(-) 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 566b2fdbc..a14932d93 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 @@ -241,5 +241,22 @@ export const COMPOSIO_TOOLKITS = [ }, ] as const; +// Skip IndexingConfigurationView and auto-index with defaults after OAuth +export const AUTO_INDEX_CONNECTOR_TYPES = new Set([ + EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, + EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, + EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR, + EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, + EnumConnectorName.AIRTABLE_CONNECTOR, + EnumConnectorName.NOTION_CONNECTOR, + EnumConnectorName.LINEAR_CONNECTOR, + EnumConnectorName.SLACK_CONNECTOR, + EnumConnectorName.TEAMS_CONNECTOR, + EnumConnectorName.DISCORD_CONNECTOR, + EnumConnectorName.JIRA_CONNECTOR, + EnumConnectorName.CONFLUENCE_CONNECTOR, + EnumConnectorName.CLICKUP_CONNECTOR, +]); + // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 4585057ff..8a2577479 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -28,6 +28,7 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; import { queryClient } from "@/lib/query-client/client"; import type { IndexingConfigState } from "../constants/connector-constants"; import { + AUTO_INDEX_CONNECTOR_TYPES, COMPOSIO_CONNECTORS, OAUTH_CONNECTORS, OTHER_CONNECTORS, @@ -119,6 +120,56 @@ export const useConnectorDialog = () => { } }, []); + const handleAutoIndex = useCallback( + async ( + connector: SearchSourceConnector, + connectorTitle: string, + connectorType: string + ) => { + if (!searchSpaceId) return; + + setIsOpen(true); + try { + await indexConnector({ + connector_id: connector.id, + queryParams: { search_space_id: searchSpaceId }, + }); + + trackIndexWithDateRangeStarted( + Number(searchSpaceId), + connectorType, + connector.id, + { hasStartDate: false, hasEndDate: false } + ); + + toast.success(`${connectorTitle} connected!`, { + description: "Syncing started. Your data will be available shortly.", + }); + } catch (error) { + console.error("Auto-index failed:", error); + toast.success(`${connectorTitle} connected!`, { + description: + "Connected successfully, but syncing could not start. You can start it from settings.", + }); + } + + const url = new URL(window.location.href); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("connectorId"); + url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + router.replace(url.pathname + url.search, { scroll: false }); + + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + await refetchAllConnectors(); + }, + [searchSpaceId, indexConnector, refetchAllConnectors, setIsOpen, router] + ); + // Synchronize state with URL query params useEffect(() => { try { @@ -336,7 +387,7 @@ export const useConnectorDialog = () => { } if (params.success === "true" && searchSpaceId && params.modal === "connectors") { - refetchAllConnectors().then((result) => { + refetchAllConnectors().then(async (result) => { if (!result.data) return; let newConnector: SearchSourceConnector | undefined; @@ -376,27 +427,37 @@ export const useConnectorDialog = () => { if (newConnector && oauthConnector) { const connectorValidation = searchSourceConnector.safeParse(newConnector); if (connectorValidation.success) { - // Track connector connected event for OAuth/Composio connectors trackConnectorConnected( Number(searchSpaceId), oauthConnector.connectorType, newConnector.id ); - const config = validateIndexingConfigState({ - connectorType: oauthConnector.connectorType, - connectorId: newConnector.id, - connectorTitle: oauthConnector.title, - }); - setIndexingConfig(config); - setIndexingConnector(newConnector); - setIndexingConnectorConfig(newConnector.config); - setIsOpen(true); - const url = new URL(window.location.href); - url.searchParams.delete("success"); - url.searchParams.set("connectorId", newConnector.id.toString()); - url.searchParams.set("view", "configure"); - window.history.replaceState({}, "", url.toString()); + if ( + newConnector.is_indexable && + AUTO_INDEX_CONNECTOR_TYPES.has(oauthConnector.connectorType) + ) { + await handleAutoIndex( + newConnector, + oauthConnector.title, + oauthConnector.connectorType + ); + } else { + const config = validateIndexingConfigState({ + connectorType: oauthConnector.connectorType, + connectorId: newConnector.id, + connectorTitle: oauthConnector.title, + }); + setIndexingConfig(config); + setIndexingConnector(newConnector); + setIndexingConnectorConfig(newConnector.config); + setIsOpen(true); + const url = new URL(window.location.href); + url.searchParams.delete("success"); + url.searchParams.set("connectorId", newConnector.id.toString()); + url.searchParams.set("view", "configure"); + window.history.replaceState({}, "", url.toString()); + } } else { console.warn("Invalid connector data after OAuth:", connectorValidation.error); toast.error("Failed to validate connector data"); @@ -408,7 +469,7 @@ export const useConnectorDialog = () => { // Invalid query params - log but don't crash console.warn("Invalid connector popup query params in OAuth success handler:", error); } - }, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen]); + }, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen, handleAutoIndex]); // Handle OAuth connection const handleConnectOAuth = useCallback( @@ -1592,6 +1653,7 @@ export const useConnectorDialog = () => { handleCreateWebcrawler, handleCreateYouTubeCrawler, handleSubmitConnectForm, + handleAutoIndex, handleStartIndexing, handleSkipIndexing, handleStartEdit, From f5db2184c7d1cc2aba71c308b3d49b82dfb5518c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 16:20:11 +0200 Subject: [PATCH 02/10] per-connector defaults, periodic sync, and double-trigger guard --- .../constants/connector-constants.ts | 38 +++++++++++-------- .../hooks/use-connector-dialog.ts | 31 +++++++++++++-- 2 files changed, 49 insertions(+), 20 deletions(-) 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 a14932d93..5d138af06 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 @@ -241,22 +241,28 @@ export const COMPOSIO_TOOLKITS = [ }, ] as const; -// Skip IndexingConfigurationView and auto-index with defaults after OAuth -export const AUTO_INDEX_CONNECTOR_TYPES = new Set([ - EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, - EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, - EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR, - EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR, - EnumConnectorName.AIRTABLE_CONNECTOR, - EnumConnectorName.NOTION_CONNECTOR, - EnumConnectorName.LINEAR_CONNECTOR, - EnumConnectorName.SLACK_CONNECTOR, - EnumConnectorName.TEAMS_CONNECTOR, - EnumConnectorName.DISCORD_CONNECTOR, - EnumConnectorName.JIRA_CONNECTOR, - EnumConnectorName.CONFLUENCE_CONNECTOR, - EnumConnectorName.CLICKUP_CONNECTOR, -]); +// Per-connector defaults for auto-indexing after OAuth (days back, days forward, periodic frequency in minutes) +export const AUTO_INDEX_DEFAULTS: Record = { + // Messaging — high volume, recent messages matter most + [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.SLACK_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.DISCORD_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.TEAMS_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, + // Calendar — past context + upcoming events + [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440 }, + [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440 }, + // Project management — medium-term relevance + [EnumConnectorName.LINEAR_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.JIRA_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.CLICKUP_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440 }, + // Knowledge bases — evergreen content + [EnumConnectorName.NOTION_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.CONFLUENCE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440 }, + [EnumConnectorName.AIRTABLE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440 }, +}; + +export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 8a2577479..57f213334 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -29,6 +29,7 @@ import { queryClient } from "@/lib/query-client/client"; import type { IndexingConfigState } from "../constants/connector-constants"; import { AUTO_INDEX_CONNECTOR_TYPES, + AUTO_INDEX_DEFAULTS, COMPOSIO_CONNECTORS, OAUTH_CONNECTORS, OTHER_CONNECTORS, @@ -81,6 +82,7 @@ export const useConnectorDialog = () => { const [connectingConnectorType, setConnectingConnectorType] = useState(null); const [isCreatingConnector, setIsCreatingConnector] = useState(false); const isCreatingConnectorRef = useRef(false); + const isAutoIndexingRef = useRef(false); // Accounts list view state (for OAuth connectors with multiple accounts) const [viewingAccountsType, setViewingAccountsType] = useState<{ @@ -126,20 +128,40 @@ export const useConnectorDialog = () => { connectorTitle: string, connectorType: string ) => { - if (!searchSpaceId) return; + if (!searchSpaceId || isAutoIndexingRef.current) return; + isAutoIndexingRef.current = true; + + const defaults = AUTO_INDEX_DEFAULTS[connectorType]; + const now = new Date(); + const startDate = new Date(now); + startDate.setDate(startDate.getDate() - (defaults?.daysBack ?? 365)); + const endDate = new Date(now); + endDate.setDate(endDate.getDate() + (defaults?.daysForward ?? 0)); setIsOpen(true); try { + await updateConnector({ + id: connector.id, + data: { + periodic_indexing_enabled: true, + indexing_frequency_minutes: defaults?.frequencyMinutes ?? 1440, + }, + }); + await indexConnector({ connector_id: connector.id, - queryParams: { search_space_id: searchSpaceId }, + queryParams: { + search_space_id: searchSpaceId, + start_date: format(startDate, "yyyy-MM-dd"), + end_date: format(endDate, "yyyy-MM-dd"), + }, }); trackIndexWithDateRangeStarted( Number(searchSpaceId), connectorType, connector.id, - { hasStartDate: false, hasEndDate: false } + { hasStartDate: true, hasEndDate: true } ); toast.success(`${connectorTitle} connected!`, { @@ -166,8 +188,9 @@ export const useConnectorDialog = () => { queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), }); await refetchAllConnectors(); + isAutoIndexingRef.current = false; }, - [searchSpaceId, indexConnector, refetchAllConnectors, setIsOpen, router] + [searchSpaceId, indexConnector, updateConnector, refetchAllConnectors, setIsOpen, router] ); // Synchronize state with URL query params From 20ca49087f64eda19ec4f5a97191379877787686 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 16:24:19 +0200 Subject: [PATCH 03/10] fix missing enable_summary in createConnector calls --- .../assistant-ui/connector-popup/hooks/use-connector-dialog.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 57f213334..b4b996b41 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -563,6 +563,7 @@ export const useConnectorDialog = () => { periodic_indexing_enabled: false, indexing_frequency_minutes: null, next_scheduled_at: null, + enable_summary: false, }, queryParams: { search_space_id: searchSpaceId, @@ -667,6 +668,7 @@ export const useConnectorDialog = () => { connector_type: connectorData.connector_type as EnumConnectorName, is_active: true, next_scheduled_at: connectorData.next_scheduled_at as string | null, + enable_summary: false, }, queryParams: { search_space_id: searchSpaceId, From dfb1c6534d8e16c8ceeebbc4a301efdff24aeb7a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 17:10:24 +0200 Subject: [PATCH 04/10] fix auto-index race conditions and orphaned toasts --- .../constants/connector-constants.ts | 40 ++++++------ .../hooks/use-connector-dialog.ts | 65 ++++++++++++------- 2 files changed, 63 insertions(+), 42 deletions(-) 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 5d138af06..137a9480e 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 @@ -241,25 +241,27 @@ export const COMPOSIO_TOOLKITS = [ }, ] as const; -// Per-connector defaults for auto-indexing after OAuth (days back, days forward, periodic frequency in minutes) -export const AUTO_INDEX_DEFAULTS: Record = { - // Messaging — high volume, recent messages matter most - [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.SLACK_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.DISCORD_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.TEAMS_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440 }, - // Calendar — past context + upcoming events - [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440 }, - [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440 }, - // Project management — medium-term relevance - [EnumConnectorName.LINEAR_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.JIRA_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.CLICKUP_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440 }, - // Knowledge bases — evergreen content - [EnumConnectorName.NOTION_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.CONFLUENCE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440 }, - [EnumConnectorName.AIRTABLE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440 }, +export interface AutoIndexConfig { + daysBack: number; + daysForward: number; + frequencyMinutes: number; + syncDescription: string; +} + +export const AUTO_INDEX_DEFAULTS: Record = { + [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of emails." }, + [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of emails." }, + [EnumConnectorName.SLACK_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of messages." }, + [EnumConnectorName.DISCORD_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of messages." }, + [EnumConnectorName.TEAMS_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of messages." }, + [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440, syncDescription: "Syncing 90 days of past and upcoming events." }, + [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440, syncDescription: "Syncing 90 days of past and upcoming events." }, + [EnumConnectorName.LINEAR_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 90 days of issues." }, + [EnumConnectorName.JIRA_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 90 days of issues." }, + [EnumConnectorName.CLICKUP_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 90 days of tasks." }, + [EnumConnectorName.NOTION_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your pages." }, + [EnumConnectorName.CONFLUENCE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your documentation." }, + [EnumConnectorName.AIRTABLE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your bases." }, }; export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index b4b996b41..8cbf08bb9 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -138,7 +138,9 @@ export const useConnectorDialog = () => { const endDate = new Date(now); endDate.setDate(endDate.getDate() + (defaults?.daysForward ?? 0)); - setIsOpen(true); + const toastId = "auto-index"; + toast.loading(`Setting up ${connectorTitle}...`, { id: toastId }); + try { await updateConnector({ id: connector.id, @@ -165,32 +167,24 @@ export const useConnectorDialog = () => { ); toast.success(`${connectorTitle} connected!`, { - description: "Syncing started. Your data will be available shortly.", + id: toastId, + description: defaults?.syncDescription ?? "Syncing started.", }); } catch (error) { console.error("Auto-index failed:", error); - toast.success(`${connectorTitle} connected!`, { - description: - "Connected successfully, but syncing could not start. You can start it from settings.", + toast.error(`${connectorTitle} connected, but sync failed`, { + id: toastId, + description: "You can start syncing from settings.", }); + } finally { + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + await refetchAllConnectors(); + isAutoIndexingRef.current = false; } - - const url = new URL(window.location.href); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("connectorId"); - url.searchParams.delete("view"); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - router.replace(url.pathname + url.search, { scroll: false }); - - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - await refetchAllConnectors(); - isAutoIndexingRef.current = false; }, - [searchSpaceId, indexConnector, updateConnector, refetchAllConnectors, setIsOpen, router] + [searchSpaceId, indexConnector, updateConnector, refetchAllConnectors] ); // Synchronize state with URL query params @@ -410,8 +404,29 @@ export const useConnectorDialog = () => { } if (params.success === "true" && searchSpaceId && params.modal === "connectors") { + // For auto-index connectors: close modal and show loading toast before refetch + const earlyConnector = params.connector + ? OAUTH_CONNECTORS.find((c) => c.id === params.connector) || + COMPOSIO_CONNECTORS.find((c) => c.id === params.connector) + : null; + + if (earlyConnector && AUTO_INDEX_CONNECTOR_TYPES.has(earlyConnector.connectorType)) { + toast.loading(`Setting up ${earlyConnector.title}...`, { id: "auto-index" }); + const url = new URL(window.location.href); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("connectorId"); + url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + router.replace(url.pathname + url.search, { scroll: false }); + } + refetchAllConnectors().then(async (result) => { - if (!result.data) return; + if (!result.data) { + toast.dismiss("auto-index"); + return; + } let newConnector: SearchSourceConnector | undefined; let oauthConnector: @@ -466,6 +481,7 @@ export const useConnectorDialog = () => { oauthConnector.connectorType ); } else { + toast.dismiss("auto-index"); const config = validateIndexingConfigState({ connectorType: oauthConnector.connectorType, connectorId: newConnector.id, @@ -483,8 +499,11 @@ export const useConnectorDialog = () => { } } else { console.warn("Invalid connector data after OAuth:", connectorValidation.error); + toast.dismiss("auto-index"); toast.error("Failed to validate connector data"); } + } else { + toast.dismiss("auto-index"); } }); } @@ -492,7 +511,7 @@ export const useConnectorDialog = () => { // Invalid query params - log but don't crash console.warn("Invalid connector popup query params in OAuth success handler:", error); } - }, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen, handleAutoIndex]); + }, [searchParams, searchSpaceId, refetchAllConnectors, setIsOpen, handleAutoIndex, router]); // Handle OAuth connection const handleConnectOAuth = useCallback( From 0bb1b730ddb0a79f099dc6441ff8e4845606f74e Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 18:57:49 +0200 Subject: [PATCH 05/10] fix Gmail HTML rendering: unwrap layout tables and use native img --- .../connectors/composio_gmail_connector.py | 16 +++++++++++++++- .../app/connectors/google_gmail_connector.py | 19 ++++++++++++++----- surfsense_web/components/markdown-viewer.tsx | 7 +++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/surfsense_backend/app/connectors/composio_gmail_connector.py b/surfsense_backend/app/connectors/composio_gmail_connector.py index e83ba5cfb..94ee7b14c 100644 --- a/surfsense_backend/app/connectors/composio_gmail_connector.py +++ b/surfsense_backend/app/connectors/composio_gmail_connector.py @@ -10,6 +10,9 @@ from collections.abc import Awaitable, Callable from datetime import UTC, datetime from typing import Any +from bs4 import BeautifulSoup +from markdownify import markdownify as md + from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload @@ -130,6 +133,16 @@ class ComposioGmailConnector(ComposioConnector): message_id=message_id, ) + @staticmethod + def _html_to_markdown(html: str) -> str: + """Convert HTML (especially email layouts with nested tables) to clean markdown.""" + soup = BeautifulSoup(html, "html.parser") + for tag in soup.find_all(["style", "script", "img"]): + tag.decompose() + for tag in soup.find_all(["table", "thead", "tbody", "tfoot", "tr", "td", "th"]): + tag.unwrap() + return md(str(soup)).strip() + def format_gmail_message_to_markdown(self, message: dict[str, Any]) -> str: """ Format a Gmail message to markdown. @@ -178,9 +191,10 @@ class ComposioGmailConnector(ComposioConnector): markdown_content += "\n---\n\n" - # Composio provides full message text in 'messageText' + # Composio provides full message text in 'messageText' which is often raw HTML message_text = message.get("messageText", "") if message_text: + message_text = self._html_to_markdown(message_text) markdown_content += f"## Content\n\n{message_text}\n\n" else: # Fallback to snippet if no messageText diff --git a/surfsense_backend/app/connectors/google_gmail_connector.py b/surfsense_backend/app/connectors/google_gmail_connector.py index 7c7262bff..46b825253 100644 --- a/surfsense_backend/app/connectors/google_gmail_connector.py +++ b/surfsense_backend/app/connectors/google_gmail_connector.py @@ -7,9 +7,11 @@ Allows fetching emails from Gmail mailbox using Google OAuth credentials. import base64 import json import logging -import re from typing import Any +from bs4 import BeautifulSoup +from markdownify import markdownify as md + from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from googleapiclient.discovery import build @@ -348,6 +350,16 @@ class GoogleGmailConnector: except Exception as e: return [], f"Error fetching recent messages: {e!s}" + @staticmethod + def _html_to_markdown(html: str) -> str: + """Convert HTML (especially email layouts with nested tables) to clean markdown.""" + soup = BeautifulSoup(html, "html.parser") + for tag in soup.find_all(["style", "script", "img"]): + tag.decompose() + for tag in soup.find_all(["table", "thead", "tbody", "tfoot", "tr", "td", "th"]): + tag.unwrap() + return md(str(soup)).strip() + def extract_message_text(self, message: dict[str, Any]) -> str: """ Extract text content from a Gmail message. @@ -387,13 +399,10 @@ class GoogleGmailConnector: ) text_content += decoded_data + "\n" elif mime_type == "text/html" and data and not text_content: - # Use HTML as fallback if no plain text decoded_data = base64.urlsafe_b64decode(data + "===").decode( "utf-8", errors="ignore" ) - # Basic HTML tag removal (you might want to use a proper HTML parser) - - text_content = re.sub(r"<[^>]+>", "", decoded_data) + text_content = self._html_to_markdown(decoded_data) return text_content.strip() diff --git a/surfsense_web/components/markdown-viewer.tsx b/surfsense_web/components/markdown-viewer.tsx index 7e0fc17b1..e22df8998 100644 --- a/surfsense_web/components/markdown-viewer.tsx +++ b/surfsense_web/components/markdown-viewer.tsx @@ -1,6 +1,5 @@ import { createCodePlugin } from "@streamdown/code"; import { createMathPlugin } from "@streamdown/math"; -import Image from "next/image"; import { Streamdown, type StreamdownProps } from "streamdown"; import "katex/dist/katex.min.css"; import { cn } from "@/lib/utils"; @@ -126,12 +125,12 @@ export function MarkdownViewer({ content, className }: MarkdownViewerProps) { ), hr: ({ ...props }) =>
, img: ({ src, alt, width: _w, height: _h, ...props }) => ( - {alt ), From 1e2c54eea6c37fd12a7bf5aab11bad0472b6c784 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 20:06:45 +0200 Subject: [PATCH 06/10] add unified drive-picker-token endpoint and GOOGLE_PICKER_API_KEY config --- surfsense_backend/.env.example | 1 + surfsense_backend/app/config/__init__.py | 1 + .../routes/search_source_connectors_routes.py | 75 +++++++++++++++++++ .../app/services/composio_service.py | 13 ++++ 4 files changed, 90 insertions(+) diff --git a/surfsense_backend/.env.example b/surfsense_backend/.env.example index 0af368081..413be03c4 100644 --- a/surfsense_backend/.env.example +++ b/surfsense_backend/.env.example @@ -50,6 +50,7 @@ REGISTRATION_ENABLED=TRUE or FALSE # For Google Auth Only GOOGLE_OAUTH_CLIENT_ID=924507538m GOOGLE_OAUTH_CLIENT_SECRET=GOCSV +GOOGLE_PICKER_API_KEY=your-google-picker-api-key # Google Connector Specific Configurations GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 68c65a818..1ddc54e2a 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -235,6 +235,7 @@ class Config: # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") GOOGLE_OAUTH_CLIENT_SECRET = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") + GOOGLE_PICKER_API_KEY = os.getenv("GOOGLE_PICKER_API_KEY") # Google Calendar redirect URI GOOGLE_CALENDAR_REDIRECT_URI = os.getenv("GOOGLE_CALENDAR_REDIRECT_URI") diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index e808635e6..64aeab431 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -3054,3 +3054,78 @@ async def test_mcp_server_connection( "message": f"Failed to test connection: {e!s}", "tools": [], } + + +# --------------------------------------------------------------------------- +# Google Picker token endpoint (unified for native & Composio Drive) +# --------------------------------------------------------------------------- + +DRIVE_CONNECTOR_TYPES = { + SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR, + SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR, +} + + +@router.get("/connectors/{connector_id}/drive-picker-token") +async def get_drive_picker_token( + connector_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Return an OAuth access token + client ID for the Google Picker API.""" + result = await session.execute( + select(SearchSourceConnector).filter(SearchSourceConnector.id == connector_id) + ) + connector = result.scalars().first() + if not connector: + raise HTTPException(status_code=404, detail="Connector not found") + + if connector.connector_type not in DRIVE_CONNECTOR_TYPES: + raise HTTPException( + status_code=400, + detail="This endpoint is only for Google Drive connectors", + ) + + picker_api_key = config.GOOGLE_PICKER_API_KEY + if not picker_api_key: + raise HTTPException( + status_code=500, + detail="GOOGLE_PICKER_API_KEY is not configured on the server", + ) + + try: + if connector.connector_type == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: + from app.connectors.google_drive.credentials import get_valid_credentials + + credentials = await get_valid_credentials(session, connector_id) + return { + "access_token": credentials.token, + "client_id": config.GOOGLE_OAUTH_CLIENT_ID, + "picker_api_key": picker_api_key, + } + + # Composio path + composio_account_id = (connector.config or {}).get( + "composio_connected_account_id" + ) + if not composio_account_id: + raise HTTPException( + status_code=400, + detail="Composio connected account not found. Please reconnect.", + ) + service = ComposioService() + access_token = service.get_access_token(composio_account_id) + return { + "access_token": access_token, + "client_id": config.GOOGLE_OAUTH_CLIENT_ID, + "picker_api_key": picker_api_key, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get Drive picker token: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to retrieve access token: {e!s}", + ) from e diff --git a/surfsense_backend/app/services/composio_service.py b/surfsense_backend/app/services/composio_service.py index 763347d5a..3930d38ad 100644 --- a/surfsense_backend/app/services/composio_service.py +++ b/surfsense_backend/app/services/composio_service.py @@ -247,6 +247,19 @@ class ComposioService: ) return False + def get_access_token(self, connected_account_id: str) -> str: + """Retrieve the raw OAuth access token for a Composio connected account.""" + account = self.client.connected_accounts.get(nanoid=connected_account_id) + token = getattr(getattr(account, "state", None), "val", None) + if token is None: + raise ValueError( + f"No state.val on connected account {connected_account_id}" + ) + access_token = getattr(token, "access_token", None) + if not access_token: + raise ValueError(f"No access_token in state.val for {connected_account_id}") + return access_token + async def execute_tool( self, connected_account_id: str, From a42a5a936c00c6daf57cd499c099c8ea9063a0f4 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 20:21:48 +0200 Subject: [PATCH 07/10] add Google Picker hook and API types --- surfsense_web/hooks/use-google-picker.ts | 151 ++++++++++++++++++ .../lib/apis/connectors-api.service.ts | 11 ++ surfsense_web/package.json | 2 + surfsense_web/pnpm-lock.yaml | 16 ++ 4 files changed, 180 insertions(+) create mode 100644 surfsense_web/hooks/use-google-picker.ts diff --git a/surfsense_web/hooks/use-google-picker.ts b/surfsense_web/hooks/use-google-picker.ts new file mode 100644 index 000000000..45e696235 --- /dev/null +++ b/surfsense_web/hooks/use-google-picker.ts @@ -0,0 +1,151 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { connectorsApiService } from "@/lib/apis/connectors-api.service"; + +export interface PickerItem { + id: string; + name: string; + mimeType: string; +} + +export interface PickerResult { + folders: PickerItem[]; + files: PickerItem[]; +} + +interface UseGooglePickerOptions { + connectorId: number; + onPicked: (result: PickerResult) => void; +} + +const PICKER_SCRIPT_URL = "https://apis.google.com/js/api.js"; +const FOLDER_MIME = "application/vnd.google-apps.folder"; + +let scriptLoadPromise: Promise | null = null; +let pickerApiPromise: Promise | null = null; + +function loadPickerScript(): Promise { + if (scriptLoadPromise) return scriptLoadPromise; + if (typeof window !== "undefined" && window.gapi) { + scriptLoadPromise = Promise.resolve(); + return scriptLoadPromise; + } + + scriptLoadPromise = new Promise((resolve, reject) => { + const script = document.createElement("script"); + script.src = PICKER_SCRIPT_URL; + script.async = true; + script.defer = true; + script.onload = () => resolve(); + script.onerror = () => { + scriptLoadPromise = null; + reject(new Error("Failed to load Google Picker script")); + }; + document.head.appendChild(script); + }); + return scriptLoadPromise; +} + +function loadPickerApi(): Promise { + if (pickerApiPromise) return pickerApiPromise; + + pickerApiPromise = new Promise((resolve, reject) => { + gapi.load("picker", { + callback: () => resolve(), + onerror: () => { + pickerApiPromise = null; + reject(new Error("Failed to load Google Picker API")); + }, + }); + }); + return pickerApiPromise; +} + +export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOptions) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const onPickedRef = useRef(onPicked); + onPickedRef.current = onPicked; + const openingRef = useRef(false); + + const openPicker = useCallback(async () => { + if (openingRef.current) return; + openingRef.current = true; + setLoading(true); + setError(null); + + try { + const [tokenData] = await Promise.all([ + connectorsApiService.getDrivePickerToken(connectorId), + loadPickerScript().then(() => loadPickerApi()), + ]); + + const { access_token, picker_api_key } = tokenData; + + const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS) + .setIncludeFolders(true) + .setSelectFolderEnabled(true); + + let pickerInstance: google.picker.Picker | null = null; + + const picker = new google.picker.PickerBuilder() + .addView(docsView) + .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) + .setOAuthToken(access_token) + .setDeveloperKey(picker_api_key) + .setOrigin(window.location.protocol + "//" + window.location.host) + .setTitle("Select files and folders to index") + .setCallback((data: google.picker.ResponseObject) => { + const action = data[google.picker.Response.ACTION]; + + if (action === google.picker.Action.PICKED) { + const docs = data[google.picker.Response.DOCUMENTS]; + if (docs) { + const folders: PickerItem[] = []; + const files: PickerItem[] = []; + + for (const doc of docs) { + const mimeType = doc[google.picker.Document.MIME_TYPE] ?? ""; + const item: PickerItem = { + id: doc[google.picker.Document.ID], + name: doc[google.picker.Document.NAME] ?? "Untitled", + mimeType, + }; + if (mimeType === FOLDER_MIME) { + folders.push(item); + } else { + files.push(item); + } + } + + onPickedRef.current({ folders, files }); + } + } + + if ( + action === google.picker.Action.PICKED || + action === google.picker.Action.CANCEL || + action === google.picker.Action.ERROR + ) { + pickerInstance?.dispose(); + pickerInstance = null; + openingRef.current = false; + } + }) + .build(); + + pickerInstance = picker; + picker.setVisible(true); + } catch (err) { + openingRef.current = false; + const msg = err instanceof Error ? err.message : "Failed to open Google Picker"; + setError(msg); + console.error("Google Picker error:", err); + } finally { + setLoading(false); + } + }, [connectorId]); + + return { openPicker, loading, error }; +} diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts index 4a6d67d80..ba607ccc1 100644 --- a/surfsense_web/lib/apis/connectors-api.service.ts +++ b/surfsense_web/lib/apis/connectors-api.service.ts @@ -266,6 +266,17 @@ class ConnectorsApiService { ); }; + /** + * Get Google Picker token (access_token + client_id + picker_api_key) for a Drive connector + */ + getDrivePickerToken = async (connectorId: number) => { + return baseApiService.get<{ + access_token: string; + client_id: string; + picker_api_key: string; + }>(`/api/v1/connectors/${connectorId}/drive-picker-token`); + }; + // ============================================================================= // MCP Connector Methods // ============================================================================= diff --git a/surfsense_web/package.json b/surfsense_web/package.json index 0789e304e..89c1757a1 100644 --- a/surfsense_web/package.json +++ b/surfsense_web/package.json @@ -144,6 +144,8 @@ "@tailwindcss/postcss": "^4.1.11", "@tailwindcss/typography": "^0.5.16", "@types/canvas-confetti": "^1.9.0", + "@types/gapi": "^0.0.47", + "@types/google.picker": "^0.0.52", "@types/node": "^20.19.9", "@types/pg": "^8.15.5", "@types/react": "^19.1.8", diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml index b78cc44c8..5dda2b6cb 100644 --- a/surfsense_web/pnpm-lock.yaml +++ b/surfsense_web/pnpm-lock.yaml @@ -372,6 +372,12 @@ importers: '@types/canvas-confetti': specifier: ^1.9.0 version: 1.9.0 + '@types/gapi': + specifier: ^0.0.47 + version: 0.0.47 + '@types/google.picker': + specifier: ^0.0.52 + version: 0.0.52 '@types/node': specifier: ^20.19.9 version: 20.19.33 @@ -3807,6 +3813,12 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/gapi@0.0.47': + resolution: {integrity: sha512-/ZsLuq6BffMgbKMtZyDZ8vwQvTyKhKQ1G2K6VyWCgtHHhfSSXbk4+4JwImZiTjWNXfI2q1ZStAwFFHSkNoTkHA==} + + '@types/google.picker@0.0.52': + resolution: {integrity: sha512-k0HyW8HxJePomM2r0JWq9nE9XG6qY93lVpoVnaV4WjQggDHrGwDKq3G8CGpcBWhQlJBTxX9jDIrI7RQnqjM63w==} + '@types/hast@2.3.10': resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} @@ -10486,6 +10498,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/gapi@0.0.47': {} + + '@types/google.picker@0.0.52': {} + '@types/hast@2.3.10': dependencies: '@types/unist': 2.0.11 From 2c9d01ba2db470b27d1232fd6f33d2811d81da28 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 20:29:09 +0200 Subject: [PATCH 08/10] replace custom folder tree with Google Picker in Drive configs --- .../components/composio-drive-config.tsx | 106 ++++++++---------- .../components/google-drive-config.tsx | 105 ++++++++--------- 2 files changed, 90 insertions(+), 121 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx index 239125565..c8aea8721 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx @@ -6,12 +6,12 @@ import { FileText, FolderClosed, Image, + Loader2, Presentation, X, } from "lucide-react"; import type { FC } from "react"; -import { useEffect, useState } from "react"; -import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { @@ -23,6 +23,7 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker"; interface ComposioDriveConfigProps { connector: SearchSourceConnector; @@ -30,7 +31,7 @@ interface ComposioDriveConfigProps { onNameChange?: (name: string) => void; } -interface SelectedFolder { +interface SelectedItem { id: string; name: string; } @@ -47,10 +48,8 @@ const DEFAULT_INDEXING_OPTIONS: IndexingOptions = { include_subfolders: true, }; -// Helper to get appropriate icon for file type based on file name function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") { const lowerName = fileName.toLowerCase(); - // Spreadsheets if ( lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls") || @@ -59,7 +58,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Presentations if ( lowerName.endsWith(".pptx") || lowerName.endsWith(".ppt") || @@ -67,7 +65,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Documents (word, text only - not PDF) if ( lowerName.endsWith(".docx") || lowerName.endsWith(".doc") || @@ -78,7 +75,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Images if ( lowerName.endsWith(".png") || lowerName.endsWith(".jpg") || @@ -89,7 +85,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Default (including PDF) return ; } @@ -99,22 +94,18 @@ export const ComposioDriveConfig: FC = ({ }) => { const isIndexable = connector.config?.is_indexable as boolean; - // Initialize with existing selected folders and files from connector config - const existingFolders = - (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; - const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || []; + const existingFolders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; + const existingFiles = (connector.config?.selected_files as SelectedItem[] | undefined) || []; const existingIndexingOptions = (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS; - const [selectedFolders, setSelectedFolders] = useState(existingFolders); - const [selectedFiles, setSelectedFiles] = useState(existingFiles); - const [showFolderSelector, setShowFolderSelector] = useState(false); + const [selectedFolders, setSelectedFolders] = useState(existingFolders); + const [selectedFiles, setSelectedFiles] = useState(existingFiles); const [indexingOptions, setIndexingOptions] = useState(existingIndexingOptions); - // Update selected folders and files when connector config changes useEffect(() => { - const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; - const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || []; + const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; + const files = (connector.config?.selected_files as SelectedItem[] | undefined) || []; const options = (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS; @@ -124,8 +115,8 @@ export const ComposioDriveConfig: FC = ({ }, [connector.config]); const updateConfig = ( - folders: SelectedFolder[], - files: SelectedFolder[], + folders: SelectedItem[], + files: SelectedItem[], options: IndexingOptions ) => { if (onConfigChange) { @@ -138,15 +129,26 @@ export const ComposioDriveConfig: FC = ({ } }; - const handleSelectFolders = (folders: SelectedFolder[]) => { - setSelectedFolders(folders); - updateConfig(folders, selectedFiles, indexingOptions); - }; + const handlePicked = useCallback( + (result: PickerResult) => { + const folders = result.folders.map((f) => ({ id: f.id, name: f.name })); + const files = result.files.map((f) => ({ id: f.id, name: f.name })); + setSelectedFolders(folders); + setSelectedFiles(files); + updateConfig(folders, files, indexingOptions); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [indexingOptions, connector.config] + ); - const handleSelectFiles = (files: SelectedFolder[]) => { - setSelectedFiles(files); - updateConfig(selectedFolders, files, indexingOptions); - }; + const { + openPicker, + loading: pickerLoading, + error: pickerError, + } = useGooglePicker({ + connectorId: connector.id, + onPicked: handlePicked, + }); const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => { const newOptions = { ...indexingOptions, [key]: value }; @@ -155,20 +157,19 @@ export const ComposioDriveConfig: FC = ({ }; const handleRemoveFolder = (folderId: string) => { - const newFolders = selectedFolders.filter((folder) => folder.id !== folderId); + const newFolders = selectedFolders.filter((f) => f.id !== folderId); setSelectedFolders(newFolders); updateConfig(newFolders, selectedFiles, indexingOptions); }; const handleRemoveFile = (fileId: string) => { - const newFiles = selectedFiles.filter((file) => file.id !== fileId); + const newFiles = selectedFiles.filter((f) => f.id !== fileId); setSelectedFiles(newFiles); updateConfig(selectedFolders, newFiles, indexingOptions); }; const totalSelected = selectedFolders.length + selectedFiles.length; - // Only show configuration if the connector is indexable if (!isIndexable) { return
; } @@ -241,35 +242,18 @@ export const ComposioDriveConfig: FC = ({
)} - {showFolderSelector ? ( -
- - -
- ) : ( - - )} + + + {pickerError &&

{pickerError}

} {/* Indexing Options */} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx index 383f6ce0e..500ee133a 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx @@ -6,12 +6,12 @@ import { FileText, FolderClosed, Image, + Loader2, Presentation, X, } from "lucide-react"; import type { FC } from "react"; -import { useEffect, useState } from "react"; -import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { @@ -22,9 +22,10 @@ import { SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; +import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker"; import type { ConnectorConfigProps } from "../index"; -interface SelectedFolder { +interface SelectedItem { id: string; name: string; } @@ -41,10 +42,8 @@ const DEFAULT_INDEXING_OPTIONS: IndexingOptions = { include_subfolders: true, }; -// Helper to get appropriate icon for file type based on file name function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") { const lowerName = fileName.toLowerCase(); - // Spreadsheets if ( lowerName.endsWith(".xlsx") || lowerName.endsWith(".xls") || @@ -53,7 +52,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Presentations if ( lowerName.endsWith(".pptx") || lowerName.endsWith(".ppt") || @@ -61,7 +59,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Documents (word, text only - not PDF) if ( lowerName.endsWith(".docx") || lowerName.endsWith(".doc") || @@ -72,7 +69,6 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Images if ( lowerName.endsWith(".png") || lowerName.endsWith(".jpg") || @@ -83,27 +79,22 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr ) { return ; } - // Default (including PDF) return ; } export const GoogleDriveConfig: FC = ({ connector, onConfigChange }) => { - // Initialize with existing selected folders and files from connector config - const existingFolders = - (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; - const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || []; + const existingFolders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; + const existingFiles = (connector.config?.selected_files as SelectedItem[] | undefined) || []; const existingIndexingOptions = (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS; - const [selectedFolders, setSelectedFolders] = useState(existingFolders); - const [selectedFiles, setSelectedFiles] = useState(existingFiles); - const [showFolderSelector, setShowFolderSelector] = useState(false); + const [selectedFolders, setSelectedFolders] = useState(existingFolders); + const [selectedFiles, setSelectedFiles] = useState(existingFiles); const [indexingOptions, setIndexingOptions] = useState(existingIndexingOptions); - // Update selected folders and files when connector config changes useEffect(() => { - const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; - const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || []; + const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; + const files = (connector.config?.selected_files as SelectedItem[] | undefined) || []; const options = (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS; @@ -113,8 +104,8 @@ export const GoogleDriveConfig: FC = ({ connector, onConfi }, [connector.config]); const updateConfig = ( - folders: SelectedFolder[], - files: SelectedFolder[], + folders: SelectedItem[], + files: SelectedItem[], options: IndexingOptions ) => { if (onConfigChange) { @@ -127,15 +118,26 @@ export const GoogleDriveConfig: FC = ({ connector, onConfi } }; - const handleSelectFolders = (folders: SelectedFolder[]) => { - setSelectedFolders(folders); - updateConfig(folders, selectedFiles, indexingOptions); - }; + const handlePicked = useCallback( + (result: PickerResult) => { + const folders = result.folders.map((f) => ({ id: f.id, name: f.name })); + const files = result.files.map((f) => ({ id: f.id, name: f.name })); + setSelectedFolders(folders); + setSelectedFiles(files); + updateConfig(folders, files, indexingOptions); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [indexingOptions, connector.config] + ); - const handleSelectFiles = (files: SelectedFolder[]) => { - setSelectedFiles(files); - updateConfig(selectedFolders, files, indexingOptions); - }; + const { + openPicker, + loading: pickerLoading, + error: pickerError, + } = useGooglePicker({ + connectorId: connector.id, + onPicked: handlePicked, + }); const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => { const newOptions = { ...indexingOptions, [key]: value }; @@ -144,13 +146,13 @@ export const GoogleDriveConfig: FC = ({ connector, onConfi }; const handleRemoveFolder = (folderId: string) => { - const newFolders = selectedFolders.filter((folder) => folder.id !== folderId); + const newFolders = selectedFolders.filter((f) => f.id !== folderId); setSelectedFolders(newFolders); updateConfig(newFolders, selectedFiles, indexingOptions); }; const handleRemoveFile = (fileId: string) => { - const newFiles = selectedFiles.filter((file) => file.id !== fileId); + const newFiles = selectedFiles.filter((f) => f.id !== fileId); setSelectedFiles(newFiles); updateConfig(selectedFolders, newFiles, indexingOptions); }; @@ -225,35 +227,18 @@ export const GoogleDriveConfig: FC = ({ connector, onConfi )} - {showFolderSelector ? ( -
- - -
- ) : ( - - )} + + + {pickerError &&

{pickerError}

} {/* Indexing Options */} From 3bda6c167952e28018623b9262712dc0ec906f76 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 23:06:33 +0200 Subject: [PATCH 09/10] revert Composio Drive to folder tree, harden Picker for native Drive --- .../assistant-ui/connector-popup.tsx | 755 +++++++++--------- .../components/composio-drive-config.tsx | 97 +-- .../constants/connector-constants.ts | 92 ++- surfsense_web/hooks/use-google-picker.ts | 48 +- .../lib/apis/connectors-api.service.ts | 2 +- 5 files changed, 558 insertions(+), 436 deletions(-) diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 489513bff..cf1235c65 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -4,8 +4,9 @@ import { useAtomValue } from "jotai"; import { AlertTriangle, Cable, Settings } from "lucide-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { type FC, forwardRef, useImperativeHandle, useMemo } from "react"; +import { type FC, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; +import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { globalNewLLMConfigsAtom, llmPreferencesAtom, @@ -19,8 +20,8 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { useConnectorsElectric } from "@/hooks/use-connectors-electric"; +import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker"; import { cn } from "@/lib/utils"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; @@ -47,400 +48,426 @@ interface ConnectorIndicatorProps { export const ConnectorIndicator = forwardRef( ({ showTrigger = true }, ref) => { - const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const searchParams = useSearchParams(); - const { data: currentUser } = useAtomValue(currentUserAtom); - const { data: preferences = {}, isFetching: preferencesLoading } = - useAtomValue(llmPreferencesAtom); - const { data: globalConfigs = [], isFetching: globalConfigsLoading } = - useAtomValue(globalNewLLMConfigsAtom); + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const searchParams = useSearchParams(); + const { data: currentUser } = useAtomValue(currentUserAtom); + const { data: preferences = {}, isFetching: preferencesLoading } = + useAtomValue(llmPreferencesAtom); + const { data: globalConfigs = [], isFetching: globalConfigsLoading } = + useAtomValue(globalNewLLMConfigsAtom); - // Check if document summary LLM is properly configured - // - If ID is 0 (Auto mode), we need global configs to be available - // - If ID is positive (user config) or negative (specific global config), it's configured - // - If ID is null/undefined, it's not configured - const docSummaryLlmId = preferences.document_summary_llm_id; - const isAutoMode = docSummaryLlmId === 0; - const hasGlobalConfigs = globalConfigs.length > 0; + // Check if document summary LLM is properly configured + // - If ID is 0 (Auto mode), we need global configs to be available + // - If ID is positive (user config) or negative (specific global config), it's configured + // - If ID is null/undefined, it's not configured + const docSummaryLlmId = preferences.document_summary_llm_id; + const isAutoMode = docSummaryLlmId === 0; + const hasGlobalConfigs = globalConfigs.length > 0; - const hasDocumentSummaryLLM = - docSummaryLlmId !== null && - docSummaryLlmId !== undefined && - // If it's Auto mode, we need global configs to actually be available - (!isAutoMode || hasGlobalConfigs); + const hasDocumentSummaryLLM = + docSummaryLlmId !== null && + docSummaryLlmId !== undefined && + // If it's Auto mode, we need global configs to actually be available + (!isAutoMode || hasGlobalConfigs); - const llmConfigLoading = preferencesLoading || globalConfigsLoading; + const llmConfigLoading = preferencesLoading || globalConfigsLoading; - // Fetch document type counts via the lightweight /type-counts endpoint (cached 10 min) - const { data: documentTypeCounts, isFetching: documentTypesLoading } = - useAtomValue(documentTypeCountsAtom); + // Fetch document type counts via the lightweight /type-counts endpoint (cached 10 min) + const { data: documentTypeCounts, isFetching: documentTypesLoading } = + useAtomValue(documentTypeCountsAtom); - // Read status inbox items from shared atom (populated by LayoutDataProvider) - // instead of creating a duplicate useInbox("status") hook. - const statusInboxItems = useAtomValue(statusInboxItemsAtom); - const inboxItems = useMemo( - () => statusInboxItems.filter((item) => item.type === "connector_indexing"), - [statusInboxItems] - ); + // Read status inbox items from shared atom (populated by LayoutDataProvider) + // instead of creating a duplicate useInbox("status") hook. + const statusInboxItems = useAtomValue(statusInboxItemsAtom); + const inboxItems = useMemo( + () => statusInboxItems.filter((item) => item.type === "connector_indexing"), + [statusInboxItems] + ); - // Check if YouTube view is active - const isYouTubeView = searchParams.get("view") === "youtube"; + // Check if YouTube view is active + const isYouTubeView = searchParams.get("view") === "youtube"; - // Use the custom hook for dialog state management - const { - isOpen, - activeTab, - connectingId, - isScrolled, - searchQuery, - indexingConfig, - indexingConnector, - indexingConnectorConfig, - editingConnector, - connectingConnectorType, - isCreatingConnector, - startDate, - endDate, - isStartingIndexing, - isSaving, - isDisconnecting, - periodicEnabled, - frequencyMinutes, - enableSummary, - allConnectors, - viewingAccountsType, - viewingMCPList, - setSearchQuery, - setStartDate, - setEndDate, - setPeriodicEnabled, - setFrequencyMinutes, - setEnableSummary, - handleOpenChange, - handleTabChange, - handleScroll, - handleConnectOAuth, - handleConnectNonOAuth, - handleCreateWebcrawler, - handleCreateYouTubeCrawler, - handleSubmitConnectForm, - handleStartIndexing, - handleSkipIndexing, - handleStartEdit, - handleSaveConnector, - handleDisconnectConnector, - handleBackFromEdit, - handleBackFromConnect, - handleBackFromYouTube, - handleViewAccountsList, - handleBackFromAccountsList, - handleBackFromMCPList, - handleAddNewMCPFromList, - handleQuickIndexConnector, - connectorConfig, - setConnectorConfig, - setIndexingConnectorConfig, - setConnectorName, - } = useConnectorDialog(); + // Use the custom hook for dialog state management + const { + isOpen, + activeTab, + connectingId, + isScrolled, + searchQuery, + indexingConfig, + indexingConnector, + indexingConnectorConfig, + editingConnector, + connectingConnectorType, + isCreatingConnector, + startDate, + endDate, + isStartingIndexing, + isSaving, + isDisconnecting, + periodicEnabled, + frequencyMinutes, + enableSummary, + allConnectors, + viewingAccountsType, + viewingMCPList, + setSearchQuery, + setStartDate, + setEndDate, + setPeriodicEnabled, + setFrequencyMinutes, + setEnableSummary, + handleOpenChange, + handleTabChange, + handleScroll, + handleConnectOAuth, + handleConnectNonOAuth, + handleCreateWebcrawler, + handleCreateYouTubeCrawler, + handleSubmitConnectForm, + handleStartIndexing, + handleSkipIndexing, + handleStartEdit, + handleSaveConnector, + handleDisconnectConnector, + handleBackFromEdit, + handleBackFromConnect, + handleBackFromYouTube, + handleViewAccountsList, + handleBackFromAccountsList, + handleBackFromMCPList, + handleAddNewMCPFromList, + handleQuickIndexConnector, + connectorConfig, + setConnectorConfig, + setIndexingConnectorConfig, + setConnectorName, + } = useConnectorDialog(); - // Fetch connectors using Electric SQL + PGlite for real-time updates - // This provides instant updates when connectors change, without polling - const { - connectors: connectorsFromElectric = [], - loading: connectorsLoading, - error: connectorsError, - refreshConnectors: refreshConnectorsElectric, - } = useConnectorsElectric(searchSpaceId); + const [pickerOpen, setPickerOpen] = useState(false); + useEffect(() => { + const onOpen = () => setPickerOpen(true); + const onClose = () => setPickerOpen(false); + window.addEventListener(PICKER_OPEN_EVENT, onOpen); + window.addEventListener(PICKER_CLOSE_EVENT, onClose); + return () => { + window.removeEventListener(PICKER_OPEN_EVENT, onOpen); + window.removeEventListener(PICKER_CLOSE_EVENT, onClose); + }; + }, []); - // Fallback to API if Electric is not available or fails - // Use Electric data if: 1) we have data, or 2) still loading without error - // Use API data if: Electric failed (has error) or finished loading with no data - const useElectricData = - connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError); - const connectors = useElectricData ? connectorsFromElectric : allConnectors || []; + // Fetch connectors using Electric SQL + PGlite for real-time updates + // This provides instant updates when connectors change, without polling + const { + connectors: connectorsFromElectric = [], + loading: connectorsLoading, + error: connectorsError, + refreshConnectors: refreshConnectorsElectric, + } = useConnectorsElectric(searchSpaceId); - // Manual refresh function that works with both Electric and API - const refreshConnectors = async () => { - if (useElectricData) { - await refreshConnectorsElectric(); - } else { - // Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom) - // The connectorsAtom will handle refetching if needed - } - }; + // Fallback to API if Electric is not available or fails + // Use Electric data if: 1) we have data, or 2) still loading without error + // Use API data if: Electric failed (has error) or finished loading with no data + const useElectricData = + connectorsFromElectric.length > 0 || (connectorsLoading && !connectorsError); + const connectors = useElectricData ? connectorsFromElectric : allConnectors || []; - // Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed - // Also clears when failed notifications are detected - const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors( - connectors as SearchSourceConnector[], - inboxItems - ); + // Manual refresh function that works with both Electric and API + const refreshConnectors = async () => { + if (useElectricData) { + await refreshConnectorsElectric(); + } else { + // Fallback: use allConnectors from useConnectorDialog (which uses connectorsAtom) + // The connectorsAtom will handle refetching if needed + } + }; - const isLoading = connectorsLoading || documentTypesLoading; + // Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed + // Also clears when failed notifications are detected + const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors( + connectors as SearchSourceConnector[], + inboxItems + ); - // Get document types that have documents in the search space - const activeDocumentTypes = documentTypeCounts - ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) - : []; + const isLoading = connectorsLoading || documentTypesLoading; - const hasConnectors = connectors.length > 0; - const hasSources = hasConnectors || activeDocumentTypes.length > 0; - const totalSourceCount = connectors.length + activeDocumentTypes.length; + // Get document types that have documents in the search space + const activeDocumentTypes = documentTypeCounts + ? Object.entries(documentTypeCounts).filter(([, count]) => count > 0) + : []; - const activeConnectorsCount = connectors.length; + const hasConnectors = connectors.length > 0; + const hasSources = hasConnectors || activeDocumentTypes.length > 0; + const totalSourceCount = connectors.length + activeDocumentTypes.length; - // Check which connectors are already connected - // Using Electric SQL + PGlite for real-time connector updates - const connectedTypes = new Set( - (connectors || []).map((c: SearchSourceConnector) => c.connector_type) - ); + const activeConnectorsCount = connectors.length; - useImperativeHandle(ref, () => ({ - open: () => handleOpenChange(true), - })); + // Check which connectors are already connected + // Using Electric SQL + PGlite for real-time connector updates + const connectedTypes = new Set( + (connectors || []).map((c: SearchSourceConnector) => c.connector_type) + ); - if (!searchSpaceId) return null; + useImperativeHandle(ref, () => ({ + open: () => handleOpenChange(true), + })); - return ( - - {showTrigger && ( - handleOpenChange(true)} - > - {isLoading ? ( - - ) : ( - <> - - {activeConnectorsCount > 0 && ( - - {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} - - )} - - )} - - )} + if (!searchSpaceId) return null; - - Manage Connectors - {/* YouTube Crawler View - shown when adding YouTube videos */} - {isYouTubeView && searchSpaceId ? ( - - ) : viewingMCPList ? ( - - ) : viewingAccountsType ? ( - { - // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS - const oauthConnector = - OAUTH_CONNECTORS.find( - (c) => c.connectorType === viewingAccountsType.connectorType - ) || - COMPOSIO_CONNECTORS.find( - (c) => c.connectorType === viewingAccountsType.connectorType - ); - if (oauthConnector) { - handleConnectOAuth(oauthConnector); - } - }} - isConnecting={connectingId !== null} - /> - ) : connectingConnectorType ? ( - handleSubmitConnectForm(formData, startIndexing)} - onBack={handleBackFromConnect} - isSubmitting={isCreatingConnector} - /> - ) : editingConnector ? ( - c.id === editingConnector.id) - ?.last_indexed_at ?? editingConnector.last_indexed_at, - }} - startDate={startDate} - endDate={endDate} - periodicEnabled={periodicEnabled} - frequencyMinutes={frequencyMinutes} - enableSummary={enableSummary} - isSaving={isSaving} - isDisconnecting={isDisconnecting} - isIndexing={indexingConnectorIds.has(editingConnector.id)} - searchSpaceId={searchSpaceId?.toString()} - onStartDateChange={setStartDate} - onEndDateChange={setEndDate} - onPeriodicEnabledChange={setPeriodicEnabled} - onFrequencyChange={setFrequencyMinutes} - onEnableSummaryChange={setEnableSummary} - onSave={() => { - startIndexing(editingConnector.id); - handleSaveConnector(() => refreshConnectors()); - }} - onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())} - onBack={handleBackFromEdit} - onQuickIndex={ - editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" - ? () => { - startIndexing(editingConnector.id); - handleQuickIndexConnector( - editingConnector.id, - editingConnector.connector_type, - stopIndexing, - startDate, - endDate - ); - } - : undefined + return ( + { + if (!open && pickerOpen) return; + handleOpenChange(open); + }} + modal={!pickerOpen} + > + {showTrigger && ( + - ) : indexingConfig ? ( - { - if (indexingConfig.connectorId) { - startIndexing(indexingConfig.connectorId); - } - handleStartIndexing(() => refreshConnectors()); - }} - onSkip={handleSkipIndexing} - /> - ) : ( - handleOpenChange(true)} > - {/* Header */} - + ) : ( + <> + + {activeConnectorsCount > 0 && ( + + {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} + + )} + + )} + + )} + + + Manage Connectors + {/* YouTube Crawler View - shown when adding YouTube videos */} + {isYouTubeView && searchSpaceId ? ( + + ) : viewingMCPList ? ( + + ) : viewingAccountsType ? ( + { + // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS + const oauthConnector = + OAUTH_CONNECTORS.find( + (c) => c.connectorType === viewingAccountsType.connectorType + ) || + COMPOSIO_CONNECTORS.find( + (c) => c.connectorType === viewingAccountsType.connectorType + ); + if (oauthConnector) { + handleConnectOAuth(oauthConnector); + } + }} + isConnecting={connectingId !== null} + /> + ) : connectingConnectorType ? ( + handleSubmitConnectForm(formData, startIndexing)} + onBack={handleBackFromConnect} + isSubmitting={isCreatingConnector} + /> + ) : editingConnector ? ( + c.id === editingConnector.id) + ?.last_indexed_at ?? editingConnector.last_indexed_at, + }} + startDate={startDate} + endDate={endDate} + periodicEnabled={periodicEnabled} + frequencyMinutes={frequencyMinutes} + enableSummary={enableSummary} + isSaving={isSaving} + isDisconnecting={isDisconnecting} + isIndexing={indexingConnectorIds.has(editingConnector.id)} + searchSpaceId={searchSpaceId?.toString()} + onStartDateChange={setStartDate} + onEndDateChange={setEndDate} + onPeriodicEnabledChange={setPeriodicEnabled} + onFrequencyChange={setFrequencyMinutes} + onEnableSummaryChange={setEnableSummary} + onSave={() => { + startIndexing(editingConnector.id); + handleSaveConnector(() => refreshConnectors()); + }} + onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())} + onBack={handleBackFromEdit} + onQuickIndex={ + editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" + ? () => { + startIndexing(editingConnector.id); + handleQuickIndexConnector( + editingConnector.id, + editingConnector.connector_type, + stopIndexing, + startDate, + endDate + ); + } + : undefined + } + onConfigChange={setConnectorConfig} + onNameChange={setConnectorName} + /> + ) : indexingConfig ? ( + { + if (indexingConfig.connectorId) { + startIndexing(indexingConfig.connectorId); + } + handleStartIndexing(() => refreshConnectors()); + }} + onSkip={handleSkipIndexing} + /> + ) : ( + + {/* Header */} + - {/* Content */} -
-
-
- {/* LLM Configuration Warning */} - {!llmConfigLoading && !hasDocumentSummaryLLM && ( - - - LLM Configuration Required - -

- {isAutoMode && !hasGlobalConfigs - ? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources." - : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."} -

- -
-
- )} + {/* Content */} +
+
+
+ {/* LLM Configuration Warning */} + {!llmConfigLoading && !hasDocumentSummaryLLM && ( + + + LLM Configuration Required + +

+ {isAutoMode && !hasGlobalConfigs + ? "Auto mode is selected but no global LLM configurations are available. Please configure a custom LLM in Settings to process and summarize documents from your connected sources." + : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."} +

+ +
+
+ )} - - + {}} + onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}} + onCreateWebcrawler={ + hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {} + } + onCreateYouTubeCrawler={ + hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {} + } + onManage={handleStartEdit} + onViewAccountsList={handleViewAccountsList} + /> + + + {}} - onConnectNonOAuth={hasDocumentSummaryLLM ? handleConnectNonOAuth : () => {}} - onCreateWebcrawler={hasDocumentSummaryLLM ? handleCreateWebcrawler : () => {}} - onCreateYouTubeCrawler={ - hasDocumentSummaryLLM ? handleCreateYouTubeCrawler : () => {} - } + onTabChange={handleTabChange} onManage={handleStartEdit} onViewAccountsList={handleViewAccountsList} /> - - - +
+ {/* Bottom fade shadow */} +
- {/* Bottom fade shadow */} -
-
- - )} - -
- ); -}); + + )} +
+
+ ); + } +); ConnectorIndicator.displayName = "ConnectorIndicator"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx index c8aea8721..66ea22e92 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/composio-drive-config.tsx @@ -6,12 +6,12 @@ import { FileText, FolderClosed, Image, - Loader2, Presentation, X, } from "lucide-react"; import type { FC } from "react"; -import { useCallback, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; +import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { @@ -23,7 +23,6 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker"; interface ComposioDriveConfigProps { connector: SearchSourceConnector; @@ -31,7 +30,7 @@ interface ComposioDriveConfigProps { onNameChange?: (name: string) => void; } -interface SelectedItem { +interface SelectedFolder { id: string; name: string; } @@ -94,18 +93,20 @@ export const ComposioDriveConfig: FC = ({ }) => { const isIndexable = connector.config?.is_indexable as boolean; - const existingFolders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; - const existingFiles = (connector.config?.selected_files as SelectedItem[] | undefined) || []; + const existingFolders = + (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; + const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || []; const existingIndexingOptions = (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS; - const [selectedFolders, setSelectedFolders] = useState(existingFolders); - const [selectedFiles, setSelectedFiles] = useState(existingFiles); + const [selectedFolders, setSelectedFolders] = useState(existingFolders); + const [selectedFiles, setSelectedFiles] = useState(existingFiles); + const [showFolderSelector, setShowFolderSelector] = useState(false); const [indexingOptions, setIndexingOptions] = useState(existingIndexingOptions); useEffect(() => { - const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; - const files = (connector.config?.selected_files as SelectedItem[] | undefined) || []; + const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; + const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || []; const options = (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS; @@ -115,8 +116,8 @@ export const ComposioDriveConfig: FC = ({ }, [connector.config]); const updateConfig = ( - folders: SelectedItem[], - files: SelectedItem[], + folders: SelectedFolder[], + files: SelectedFolder[], options: IndexingOptions ) => { if (onConfigChange) { @@ -129,26 +130,15 @@ export const ComposioDriveConfig: FC = ({ } }; - const handlePicked = useCallback( - (result: PickerResult) => { - const folders = result.folders.map((f) => ({ id: f.id, name: f.name })); - const files = result.files.map((f) => ({ id: f.id, name: f.name })); - setSelectedFolders(folders); - setSelectedFiles(files); - updateConfig(folders, files, indexingOptions); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [indexingOptions, connector.config] - ); + const handleSelectFolders = (folders: SelectedFolder[]) => { + setSelectedFolders(folders); + updateConfig(folders, selectedFiles, indexingOptions); + }; - const { - openPicker, - loading: pickerLoading, - error: pickerError, - } = useGooglePicker({ - connectorId: connector.id, - onPicked: handlePicked, - }); + const handleSelectFiles = (files: SelectedFolder[]) => { + setSelectedFiles(files); + updateConfig(selectedFolders, files, indexingOptions); + }; const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => { const newOptions = { ...indexingOptions, [key]: value }; @@ -157,13 +147,13 @@ export const ComposioDriveConfig: FC = ({ }; const handleRemoveFolder = (folderId: string) => { - const newFolders = selectedFolders.filter((f) => f.id !== folderId); + const newFolders = selectedFolders.filter((folder) => folder.id !== folderId); setSelectedFolders(newFolders); updateConfig(newFolders, selectedFiles, indexingOptions); }; const handleRemoveFile = (fileId: string) => { - const newFiles = selectedFiles.filter((f) => f.id !== fileId); + const newFiles = selectedFiles.filter((file) => file.id !== fileId); setSelectedFiles(newFiles); updateConfig(selectedFolders, newFiles, indexingOptions); }; @@ -242,18 +232,35 @@ export const ComposioDriveConfig: FC = ({ )} - - - {pickerError &&

{pickerError}

} + {showFolderSelector ? ( +
+ + +
+ ) : ( + + )} {/* Indexing Options */} 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 137a9480e..9889708d7 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 @@ -2,7 +2,6 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; // OAuth Connectors (Quick Connect) export const OAUTH_CONNECTORS = [ - // // Uncomment for managed Google Connections // { // id: "google-drive-connector", // title: "Google Drive", @@ -249,19 +248,84 @@ export interface AutoIndexConfig { } export const AUTO_INDEX_DEFAULTS: Record = { - [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of emails." }, - [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of emails." }, - [EnumConnectorName.SLACK_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of messages." }, - [EnumConnectorName.DISCORD_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of messages." }, - [EnumConnectorName.TEAMS_CONNECTOR]: { daysBack: 30, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 30 days of messages." }, - [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440, syncDescription: "Syncing 90 days of past and upcoming events." }, - [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: { daysBack: 90, daysForward: 90, frequencyMinutes: 1440, syncDescription: "Syncing 90 days of past and upcoming events." }, - [EnumConnectorName.LINEAR_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 90 days of issues." }, - [EnumConnectorName.JIRA_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 90 days of issues." }, - [EnumConnectorName.CLICKUP_CONNECTOR]: { daysBack: 90, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your last 90 days of tasks." }, - [EnumConnectorName.NOTION_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your pages." }, - [EnumConnectorName.CONFLUENCE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your documentation." }, - [EnumConnectorName.AIRTABLE_CONNECTOR]: { daysBack: 365, daysForward: 0, frequencyMinutes: 1440, syncDescription: "Syncing your bases." }, + [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: { + daysBack: 30, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 30 days of emails.", + }, + [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: { + daysBack: 30, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 30 days of emails.", + }, + [EnumConnectorName.SLACK_CONNECTOR]: { + daysBack: 30, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 30 days of messages.", + }, + [EnumConnectorName.DISCORD_CONNECTOR]: { + daysBack: 30, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 30 days of messages.", + }, + [EnumConnectorName.TEAMS_CONNECTOR]: { + daysBack: 30, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 30 days of messages.", + }, + [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: { + daysBack: 90, + daysForward: 90, + frequencyMinutes: 1440, + syncDescription: "Syncing 90 days of past and upcoming events.", + }, + [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: { + daysBack: 90, + daysForward: 90, + frequencyMinutes: 1440, + syncDescription: "Syncing 90 days of past and upcoming events.", + }, + [EnumConnectorName.LINEAR_CONNECTOR]: { + daysBack: 90, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 90 days of issues.", + }, + [EnumConnectorName.JIRA_CONNECTOR]: { + daysBack: 90, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 90 days of issues.", + }, + [EnumConnectorName.CLICKUP_CONNECTOR]: { + daysBack: 90, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your last 90 days of tasks.", + }, + [EnumConnectorName.NOTION_CONNECTOR]: { + daysBack: 365, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your pages.", + }, + [EnumConnectorName.CONFLUENCE_CONNECTOR]: { + daysBack: 365, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your documentation.", + }, + [EnumConnectorName.AIRTABLE_CONNECTOR]: { + daysBack: 365, + daysForward: 0, + frequencyMinutes: 1440, + syncDescription: "Syncing your bases.", + }, }; export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX_DEFAULTS)); diff --git a/surfsense_web/hooks/use-google-picker.ts b/surfsense_web/hooks/use-google-picker.ts index 45e696235..6aa019b0f 100644 --- a/surfsense_web/hooks/use-google-picker.ts +++ b/surfsense_web/hooks/use-google-picker.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { connectorsApiService } from "@/lib/apis/connectors-api.service"; export interface PickerItem { @@ -21,6 +21,8 @@ interface UseGooglePickerOptions { const PICKER_SCRIPT_URL = "https://apis.google.com/js/api.js"; const FOLDER_MIME = "application/vnd.google-apps.folder"; +export const PICKER_OPEN_EVENT = "google-picker-open"; +export const PICKER_CLOSE_EVENT = "google-picker-close"; let scriptLoadPromise: Promise | null = null; let pickerApiPromise: Promise | null = null; @@ -68,6 +70,25 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption const onPickedRef = useRef(onPicked); onPickedRef.current = onPicked; const openingRef = useRef(false); + const pickerRef = useRef(null); + + const closePicker = useCallback(() => { + if (!pickerRef.current) return; + window.dispatchEvent(new Event(PICKER_CLOSE_EVENT)); + pickerRef.current.dispose(); + pickerRef.current = null; + openingRef.current = false; + }, []); + + useEffect(() => { + const onEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && pickerRef.current) { + closePicker(); + } + }; + window.addEventListener("keydown", onEscape); + return () => window.removeEventListener("keydown", onEscape); + }, [closePicker]); const openPicker = useCallback(async () => { if (openingRef.current) return; @@ -87,15 +108,18 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption .setIncludeFolders(true) .setSelectFolderEnabled(true); - let pickerInstance: google.picker.Picker | null = null; - - const picker = new google.picker.PickerBuilder() + const builder = new google.picker.PickerBuilder() .addView(docsView) .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) .setOAuthToken(access_token) - .setDeveloperKey(picker_api_key) .setOrigin(window.location.protocol + "//" + window.location.host) - .setTitle("Select files and folders to index") + .setTitle("Select files and folders to index"); + + if (picker_api_key) { + builder.setDeveloperKey(picker_api_key); + } + + const picker = builder .setCallback((data: google.picker.ResponseObject) => { const action = data[google.picker.Response.ACTION]; @@ -128,16 +152,16 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption action === google.picker.Action.CANCEL || action === google.picker.Action.ERROR ) { - pickerInstance?.dispose(); - pickerInstance = null; - openingRef.current = false; + closePicker(); } }) .build(); - pickerInstance = picker; + pickerRef.current = picker; + window.dispatchEvent(new Event(PICKER_OPEN_EVENT)); picker.setVisible(true); } catch (err) { + window.dispatchEvent(new Event(PICKER_CLOSE_EVENT)); openingRef.current = false; const msg = err instanceof Error ? err.message : "Failed to open Google Picker"; setError(msg); @@ -145,7 +169,7 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption } finally { setLoading(false); } - }, [connectorId]); + }, [connectorId, closePicker]); - return { openPicker, loading, error }; + return { openPicker, closePicker, loading, error }; } diff --git a/surfsense_web/lib/apis/connectors-api.service.ts b/surfsense_web/lib/apis/connectors-api.service.ts index ba607ccc1..fafe1a8fa 100644 --- a/surfsense_web/lib/apis/connectors-api.service.ts +++ b/surfsense_web/lib/apis/connectors-api.service.ts @@ -273,7 +273,7 @@ class ConnectorsApiService { return baseApiService.get<{ access_token: string; client_id: string; - picker_api_key: string; + picker_api_key: string | null; }>(`/api/v1/connectors/${connectorId}/drive-picker-token`); }; From cf8f70da2b771bce9d7fe0432c76652e8f73a3cc Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 10 Mar 2026 23:21:35 +0200 Subject: [PATCH 10/10] fix auth bypass on picker endpoint, async safety, and picker error handling - Add check_permission to drive-picker-token endpoint (IDOR fix) - Use get_composio_service singleton + asyncio.to_thread to avoid blocking the event loop - Sanitize error detail in 500 response to prevent internal info leakage - Dispose picker on unmount to prevent orphaned overlay - Surface error state on Google Picker Action.ERROR instead of silently closing --- .../routes/search_source_connectors_routes.py | 18 ++++++++++--- surfsense_web/hooks/use-google-picker.ts | 27 +++++++++++++------ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index 64aeab431..800eb5629 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -52,7 +52,9 @@ from app.schemas import ( SearchSourceConnectorRead, SearchSourceConnectorUpdate, ) -from app.services.composio_service import ComposioService +import asyncio + +from app.services.composio_service import ComposioService, get_composio_service from app.services.notification_service import NotificationService from app.tasks.connector_indexers import ( index_airtable_records, @@ -3080,6 +3082,14 @@ async def get_drive_picker_token( if not connector: raise HTTPException(status_code=404, detail="Connector not found") + await check_permission( + session, + user, + connector.search_space_id, + Permission.CONNECTORS_READ.value, + "You don't have permission to access this connector", + ) + if connector.connector_type not in DRIVE_CONNECTOR_TYPES: raise HTTPException( status_code=400, @@ -3113,8 +3123,8 @@ async def get_drive_picker_token( status_code=400, detail="Composio connected account not found. Please reconnect.", ) - service = ComposioService() - access_token = service.get_access_token(composio_account_id) + service = get_composio_service() + access_token = await asyncio.to_thread(service.get_access_token, composio_account_id) return { "access_token": access_token, "client_id": config.GOOGLE_OAUTH_CLIENT_ID, @@ -3127,5 +3137,5 @@ async def get_drive_picker_token( logger.error(f"Failed to get Drive picker token: {e!s}", exc_info=True) raise HTTPException( status_code=500, - detail=f"Failed to retrieve access token: {e!s}", + detail="Failed to retrieve access token. Check server logs for details.", ) from e diff --git a/surfsense_web/hooks/use-google-picker.ts b/surfsense_web/hooks/use-google-picker.ts index 6aa019b0f..fa2a159b9 100644 --- a/surfsense_web/hooks/use-google-picker.ts +++ b/surfsense_web/hooks/use-google-picker.ts @@ -87,7 +87,14 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption } }; window.addEventListener("keydown", onEscape); - return () => window.removeEventListener("keydown", onEscape); + return () => { + window.removeEventListener("keydown", onEscape); + if (pickerRef.current) { + pickerRef.current.dispose(); + pickerRef.current = null; + } + openingRef.current = false; + }; }, [closePicker]); const openPicker = useCallback(async () => { @@ -147,13 +154,17 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption } } - if ( - action === google.picker.Action.PICKED || - action === google.picker.Action.CANCEL || - action === google.picker.Action.ERROR - ) { - closePicker(); - } + if (action === google.picker.Action.ERROR) { + setError("Google Drive encountered an error. Please try again."); + } + + if ( + action === google.picker.Action.PICKED || + action === google.picker.Action.CANCEL || + action === google.picker.Action.ERROR + ) { + closePicker(); + } }) .build();