diff --git a/surfsense_backend/app/routes/google_drive_add_connector_route.py b/surfsense_backend/app/routes/google_drive_add_connector_route.py index d11404781..73ad76409 100644 --- a/surfsense_backend/app/routes/google_drive_add_connector_route.py +++ b/surfsense_backend/app/routes/google_drive_add_connector_route.py @@ -208,9 +208,8 @@ async def drive_callback( f"Successfully created Google Drive connector {db_connector.id} for user {user_id}" ) - # Redirect to connectors management page (not to folder selection) return RedirectResponse( - url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors?success=google-drive-connected" + url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-drive-connector" ) except HTTPException: diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 1e9e09869..c1ed38e7b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -17,11 +17,12 @@ import { } from "@/components/ui/tabs"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { cn } from "@/lib/utils"; -import { AllConnectorsTab } from "./connector-popup/all-connectors-tab"; -import { ActiveConnectorsTab } from "./connector-popup/active-connectors-tab"; -import { ConnectorDialogHeader } from "./connector-popup/connector-dialog-header"; -import { IndexingConfigurationView } from "./connector-popup/indexing-configuration-view"; -import { useConnectorDialog } from "./connector-popup/use-connector-dialog"; +import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; +import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab"; +import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; +import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; +import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view"; +import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; export const ConnectorIndicator: FC = () => { @@ -67,9 +68,14 @@ export const ConnectorIndicator: FC = () => { isScrolled, searchQuery, indexingConfig, + indexingConnector, + indexingConnectorConfig, + editingConnector, startDate, endDate, isStartingIndexing, + isSaving, + isDisconnecting, periodicEnabled, frequencyMinutes, allConnectors, @@ -84,6 +90,13 @@ export const ConnectorIndicator: FC = () => { handleConnectOAuth, handleStartIndexing, handleSkipIndexing, + handleStartEdit, + handleSaveConnector, + handleDisconnectConnector, + handleBackFromEdit, + connectorConfig, + setConnectorConfig, + setIndexingConnectorConfig, } = useConnectorDialog(); // Get document types that have documents in the search space @@ -133,10 +146,35 @@ export const ConnectorIndicator: FC = () => { - {/* Indexing Configuration View - shown after OAuth success */} - {indexingConfig ? ( + {/* Connector Edit View - shown when editing existing connector */} + {editingConnector ? ( + handleSaveConnector(refreshConnectors)} + onDisconnect={() => handleDisconnectConnector(refreshConnectors)} + onBack={handleBackFromEdit} + onConfigChange={setConnectorConfig} + /> + ) : indexingConfig ? ( { onEndDateChange={setEndDate} onPeriodicEnabledChange={setPeriodicEnabled} onFrequencyChange={setFrequencyMinutes} + onConfigChange={setIndexingConnectorConfig} onStartIndexing={() => handleStartIndexing(refreshConnectors)} onSkip={handleSkipIndexing} /> @@ -171,7 +210,9 @@ export const ConnectorIndicator: FC = () => { searchSpaceId={searchSpaceId} connectedTypes={connectedTypes} connectingId={connectingId} + allConnectors={allConnectors} onConnectOAuth={handleConnectOAuth} + onManage={handleStartEdit} /> @@ -184,6 +225,7 @@ export const ConnectorIndicator: FC = () => { logsSummary={logsSummary} searchSpaceId={searchSpaceId} onTabChange={handleTabChange} + onManage={handleStartEdit} /> diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/connector-card.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/connector-dialog-header.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/connector-dialog-header.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/date-range-selector.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx diff --git a/surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx similarity index 100% rename from surfsense_web/components/assistant-ui/connector-popup/periodic-sync-config.tsx rename to surfsense_web/components/assistant-ui/connector-popup/components/periodic-sync-config.tsx 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 new file mode 100644 index 000000000..280d6ed23 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/google-drive-config.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { Info } from "lucide-react"; +import { useState, useEffect } from "react"; +import type { FC } from "react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree"; +import type { ConnectorConfigProps } from "../index"; + +interface SelectedFolder { + id: string; + name: string; +} + +export const GoogleDriveConfig: FC = ({ + connector, + onConfigChange, +}) => { + // Initialize with existing selected folders from connector config + const existingFolders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; + const [selectedFolders, setSelectedFolders] = useState(existingFolders); + const [showFolderSelector, setShowFolderSelector] = useState(false); + + // Update selected folders when connector config changes + useEffect(() => { + const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || []; + setSelectedFolders(folders); + }, [connector.config]); + + const handleSelectFolders = (folders: SelectedFolder[]) => { + setSelectedFolders(folders); + if (onConfigChange) { + // Store folder IDs and names in config for indexing + onConfigChange({ + ...connector.config, + selected_folders: folders, + }); + } + }; + + return ( +
+
+

Folder Selection

+

+ Select specific folders to index. Only files directly in each folder will be processed—subfolders must be selected separately. +

+
+ + {selectedFolders.length > 0 && ( +
+

+ Selected {selectedFolders.length} folder{selectedFolders.length > 1 ? "s" : ""}: +

+
+ {selectedFolders.map((folder) => ( +

+ • {folder.name} +

+ ))} +
+
+ )} + + {showFolderSelector ? ( +
+ + +
+ ) : ( + + )} + + + + + Folder selection is used when indexing. You can change this selection when you start indexing. + + +
+ ); +}; + 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 new file mode 100644 index 000000000..eb2594ad6 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx @@ -0,0 +1,28 @@ +"use client"; + +import type { FC } from "react"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { GoogleDriveConfig } from "./components/google-drive-config"; + +export interface ConnectorConfigProps { + connector: SearchSourceConnector; + onConfigChange?: (config: Record) => void; +} + +export type ConnectorConfigComponent = FC; + +/** + * Factory function to get the appropriate config component for a connector type + */ +export function getConnectorConfigComponent( + connectorType: string +): ConnectorConfigComponent | null { + switch (connectorType) { + case "GOOGLE_DRIVE_CONNECTOR": + return GoogleDriveConfig; + // OAuth connectors (Gmail, Calendar, Airtable) 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-edit-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx new file mode 100644 index 000000000..cc4f3a815 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/connector-edit-view.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { ArrowLeft, Loader2, Trash2 } from "lucide-react"; +import { type FC, useState, useCallback, useRef, useEffect, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { cn } from "@/lib/utils"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; +import { getConnectorConfigComponent } from "../index"; + +interface ConnectorEditViewProps { + connector: SearchSourceConnector; + startDate: Date | undefined; + endDate: Date | undefined; + periodicEnabled: boolean; + frequencyMinutes: string; + isSaving: boolean; + isDisconnecting: boolean; + onStartDateChange: (date: Date | undefined) => void; + onEndDateChange: (date: Date | undefined) => void; + onPeriodicEnabledChange: (enabled: boolean) => void; + onFrequencyChange: (frequency: string) => void; + onSave: () => void; + onDisconnect: () => void; + onBack: () => void; + onConfigChange?: (config: Record) => void; +} + +export const ConnectorEditView: FC = ({ + connector, + startDate, + endDate, + periodicEnabled, + frequencyMinutes, + isSaving, + isDisconnecting, + onStartDateChange, + onEndDateChange, + onPeriodicEnabledChange, + onFrequencyChange, + onSave, + onDisconnect, + onBack, + onConfigChange, +}) => { + // Get connector-specific config component + const ConnectorConfigComponent = useMemo( + () => getConnectorConfigComponent(connector.connector_type), + [connector.connector_type] + ); + const [isScrolled, setIsScrolled] = useState(false); + const [hasMoreContent, setHasMoreContent] = useState(false); + const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false); + const scrollContainerRef = useRef(null); + + const checkScrollState = useCallback(() => { + if (!scrollContainerRef.current) return; + + const target = scrollContainerRef.current; + const scrolled = target.scrollTop > 0; + const hasMore = target.scrollHeight > target.clientHeight && + target.scrollTop + target.clientHeight < target.scrollHeight - 10; + + setIsScrolled(scrolled); + setHasMoreContent(hasMore); + }, []); + + const handleScroll = useCallback(() => { + checkScrollState(); + }, [checkScrollState]); + + // Check initial scroll state and on resize + useEffect(() => { + checkScrollState(); + const resizeObserver = new ResizeObserver(() => { + checkScrollState(); + }); + + if (scrollContainerRef.current) { + resizeObserver.observe(scrollContainerRef.current); + } + + return () => { + resizeObserver.disconnect(); + }; + }, [checkScrollState]); + + const handleDisconnectClick = () => { + setShowDisconnectConfirm(true); + }; + + const handleDisconnectConfirm = () => { + setShowDisconnectConfirm(false); + onDisconnect(); + }; + + const handleDisconnectCancel = () => { + setShowDisconnectConfirm(false); + }; + + return ( +
+ {/* Fixed Header */} +
+ {/* Back button */} + + + {/* Connector header */} +
+
+ {getConnectorIcon(connector.connector_type, "size-7")} +
+
+

+ {connector.name} +

+

+ Manage your connector settings and sync configuration +

+
+
+
+ + {/* Scrollable Content */} +
+
+
+ {/* Connector-specific configuration */} + {ConnectorConfigComponent && ( + + )} + + {/* Date range selector - not shown for Google Drive (uses folder selection instead) */} + {connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && ( + + )} + + + + {/* Info box */} +
+
+ {getConnectorIcon(connector.connector_type, "size-4")} +
+
+

Re-indexing runs in the background

+

+ You can continue using SurfSense while we sync your data. Check the Active tab to see progress. +

+
+
+
+
+ {/* Top fade shadow - appears when scrolled */} + {isScrolled && ( +
+ )} + {/* Bottom fade shadow - appears when there's more content */} + {hasMoreContent && ( +
+ )} +
+ + {/* Fixed Footer - Action buttons */} +
+ {showDisconnectConfirm ? ( +
+ Are you sure? + + +
+ ) : ( + + )} + +
+
+ ); +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx similarity index 79% rename from surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx rename to surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx index 3135c634b..2c11eb415 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/views/indexing-configuration-view.tsx @@ -1,16 +1,19 @@ "use client"; import { ArrowLeft, Check, Loader2 } from "lucide-react"; -import { type FC, useState, useCallback, useRef, useEffect } from "react"; +import { type FC, useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { cn } from "@/lib/utils"; -import type { IndexingConfigState } from "./connector-constants"; -import { DateRangeSelector } from "./date-range-selector"; -import { PeriodicSyncConfig } from "./periodic-sync-config"; +import type { IndexingConfigState } from "../../constants/connector-constants"; +import { DateRangeSelector } from "../../components/date-range-selector"; +import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; +import { getConnectorConfigComponent } from "../index"; interface IndexingConfigurationViewProps { config: IndexingConfigState; + connector?: SearchSourceConnector; startDate: Date | undefined; endDate: Date | undefined; periodicEnabled: boolean; @@ -20,12 +23,14 @@ interface IndexingConfigurationViewProps { onEndDateChange: (date: Date | undefined) => void; onPeriodicEnabledChange: (enabled: boolean) => void; onFrequencyChange: (frequency: string) => void; + onConfigChange?: (config: Record) => void; onStartIndexing: () => void; onSkip: () => void; } export const IndexingConfigurationView: FC = ({ config, + connector, startDate, endDate, periodicEnabled, @@ -35,9 +40,15 @@ export const IndexingConfigurationView: FC = ({ onEndDateChange, onPeriodicEnabledChange, onFrequencyChange, + onConfigChange, onStartIndexing, onSkip, }) => { + // Get connector-specific config component + const ConnectorConfigComponent = useMemo( + () => connector ? getConnectorConfigComponent(connector.connector_type) : null, + [connector] + ); const [isScrolled, setIsScrolled] = useState(false); const [hasMoreContent, setHasMoreContent] = useState(false); const scrollContainerRef = useRef(null); @@ -115,12 +126,23 @@ export const IndexingConfigurationView: FC = ({ onScroll={handleScroll} >
- + {/* Connector-specific configuration */} + {ConnectorConfigComponent && connector && ( + + )} + + {/* Date range selector - not shown for Google Drive (uses folder selection instead) */} + {config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && ( + + )} { + const router = useRouter(); + const searchParams = useSearchParams(); + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); + const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); + const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); + const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom); + + const [isOpen, setIsOpen] = useState(false); + const [activeTab, setActiveTab] = useState("all"); + const [connectingId, setConnectingId] = useState(null); + const [isScrolled, setIsScrolled] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [indexingConfig, setIndexingConfig] = useState(null); + const [indexingConnector, setIndexingConnector] = useState(null); + const [indexingConnectorConfig, setIndexingConnectorConfig] = useState | null>(null); + const [startDate, setStartDate] = useState(undefined); + const [endDate, setEndDate] = useState(undefined); + const [isStartingIndexing, setIsStartingIndexing] = useState(false); + const [periodicEnabled, setPeriodicEnabled] = useState(false); + const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); + + // Edit mode state + const [editingConnector, setEditingConnector] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [connectorConfig, setConnectorConfig] = useState | null>(null); + + // Helper function to get frequency label + const getFrequencyLabel = useCallback((minutes: string): string => { + switch (minutes) { + case "15": return "15 minutes"; + case "60": return "hour"; + case "360": return "6 hours"; + case "720": return "12 hours"; + case "1440": return "day"; + case "10080": return "week"; + default: return `${minutes} minutes`; + } + }, []); + + // Synchronize state with URL query params + useEffect(() => { + try { + const params = parseConnectorPopupQueryParams(searchParams); + + if (params.modal === "connectors") { + setIsOpen(true); + + if (params.tab === "active" || params.tab === "all") { + setActiveTab(params.tab); + } + + // Clear indexing config if view is not "configure" anymore + if (params.view !== "configure" && indexingConfig) { + setIndexingConfig(null); + } + + // Clear editing connector if view is not "edit" anymore + if (params.view !== "edit" && editingConnector) { + setEditingConnector(null); + } + + if (params.view === "configure" && params.connector && !indexingConfig) { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); + if (oauthConnector && allConnectors) { + const existingConnector = allConnectors.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (existingConnector) { + // Validate connector data before setting state + const connectorValidation = searchSourceConnector.safeParse(existingConnector); + if (connectorValidation.success) { + const config = validateIndexingConfigState({ + connectorType: oauthConnector.connectorType, + connectorId: existingConnector.id, + connectorTitle: oauthConnector.title, + }); + setIndexingConfig(config); + setIndexingConnector(existingConnector); + setIndexingConnectorConfig(existingConnector.config); + } + } + } + } + + // Handle edit view + if (params.view === "edit" && params.connectorId && allConnectors && !editingConnector) { + const connectorId = parseInt(params.connectorId, 10); + const connector = allConnectors.find((c: SearchSourceConnector) => c.id === connectorId); + if (connector) { + const connectorValidation = searchSourceConnector.safeParse(connector); + if (connectorValidation.success) { + setEditingConnector(connector); + setConnectorConfig(connector.config); + // Load existing periodic sync settings + setPeriodicEnabled(connector.periodic_indexing_enabled); + setFrequencyMinutes( + connector.indexing_frequency_minutes?.toString() || "1440" + ); + // Reset dates - user can set new ones for re-indexing + setStartDate(undefined); + setEndDate(undefined); + } + } + } + } else { + setIsOpen(false); + // Clear indexing config when modal is closed + if (indexingConfig) { + setIndexingConfig(null); + setIndexingConnector(null); + setIndexingConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setIsScrolled(false); + setSearchQuery(""); + } + // Clear editing connector when modal is closed + if (editingConnector) { + setEditingConnector(null); + setConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + setIsScrolled(false); + setSearchQuery(""); + } + } + } catch (error) { + // Invalid query params - log but don't crash + console.warn("Invalid connector popup query params:", error); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, allConnectors, editingConnector, indexingConfig]); + + // Detect OAuth success and transition to config view + useEffect(() => { + try { + const params = parseConnectorPopupQueryParams(searchParams); + + if (params.success === "true" && params.connector && searchSpaceId && params.modal === "connectors") { + const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); + if (oauthConnector) { + refetchAllConnectors().then((result) => { + if (!result.data) return; + + const newConnector = result.data.find( + (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType + ); + if (newConnector) { + // Validate connector data before setting state + const connectorValidation = searchSourceConnector.safeParse(newConnector); + if (connectorValidation.success) { + 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("view", "configure"); + window.history.replaceState({}, "", url.toString()); + } else { + console.warn("Invalid connector data after OAuth:", connectorValidation.error); + toast.error("Failed to validate connector data"); + } + } + }); + } + } + } catch (error) { + // Invalid query params - log but don't crash + console.warn("Invalid connector popup query params in OAuth success handler:", error); + } + }, [searchParams, searchSpaceId, refetchAllConnectors]); + + // Handle OAuth connection + const handleConnectOAuth = useCallback( + async (connector: (typeof OAUTH_CONNECTORS)[0]) => { + if (!searchSpaceId || !connector.authEndpoint) return; + + // Set connecting state immediately to disable button and show spinner + setConnectingId(connector.id); + + try { + const response = await authenticatedFetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, + { method: "GET" } + ); + + if (!response.ok) { + throw new Error(`Failed to initiate ${connector.title} OAuth`); + } + + const data = await response.json(); + + // Validate OAuth response with Zod + const validatedData = parseOAuthAuthResponse(data); + + // Don't clear connectingId here - let the redirect happen with button still disabled + // The component will unmount on redirect anyway + window.location.href = validatedData.auth_url; + } catch (error) { + console.error(`Error connecting to ${connector.title}:`, error); + if (error instanceof Error && error.message.includes("Invalid auth URL")) { + toast.error(`Invalid response from ${connector.title} OAuth endpoint`); + } else { + toast.error(`Failed to connect to ${connector.title}`); + } + // Only clear connectingId on error so user can retry + setConnectingId(null); + } + }, + [searchSpaceId] + ); + + // Handle starting indexing + const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => { + if (!indexingConfig || !searchSpaceId) return; + + // Validate date range + const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); + if (!dateRangeValidation.success) { + const firstIssueMsg = + dateRangeValidation.error.issues && dateRangeValidation.error.issues.length > 0 + ? dateRangeValidation.error.issues[0].message + : "Invalid date range"; + toast.error(firstIssueMsg); + return; + } + + // Validate frequency minutes if periodic is enabled + if (periodicEnabled) { + const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); + if (!frequencyValidation.success) { + toast.error("Invalid frequency value"); + return; + } + } + + setIsStartingIndexing(true); + try { + const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; + const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; + + // Update connector with periodic sync settings and config changes + if (periodicEnabled || indexingConnectorConfig) { + const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : undefined; + await updateConnector({ + id: indexingConfig.connectorId, + data: { + ...(periodicEnabled && { + periodic_indexing_enabled: true, + indexing_frequency_minutes: frequency, + }), + ...(indexingConnectorConfig && { + config: indexingConnectorConfig, + }), + }, + }); + } + + // Handle Google Drive folder selection + if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) { + const selectedFolders = indexingConnectorConfig.selected_folders as Array<{ id: string; name: string }> | undefined; + if (selectedFolders && selectedFolders.length > 0) { + // Index with folder selection + const folderIds = selectedFolders.map((f) => f.id).join(","); + const folderNames = selectedFolders.map((f) => f.name).join(", "); + await indexConnector({ + connector_id: indexingConfig.connectorId, + queryParams: { + search_space_id: searchSpaceId, + folder_ids: folderIds, + folder_names: folderNames, + }, + }); + } else { + // Google Drive requires folder selection - show error if none selected + toast.error("Please select at least one folder to index"); + setIsStartingIndexing(false); + return; + } + } else { + await indexConnector({ + connector_id: indexingConfig.connectorId, + queryParams: { + search_space_id: searchSpaceId, + start_date: startDateStr, + end_date: endDateStr, + }, + }); + } + + toast.success(`${indexingConfig.connectorTitle} indexing started`, { + description: periodicEnabled + ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` + : "You can continue working while we sync your data.", + }); + + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error starting indexing:", error); + toast.error("Failed to start indexing"); + } finally { + setIsStartingIndexing(false); + } + }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, indexingConnectorConfig]); + + // Handle skipping indexing + const handleSkipIndexing = useCallback(() => { + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); + + // Handle starting edit mode + const handleStartEdit = useCallback((connector: SearchSourceConnector) => { + if (!searchSpaceId) return; + + // Check if this is an OAuth connector + const isOAuthConnector = OAUTH_CONNECTORS.some( + (oauthConnector) => oauthConnector.connectorType === connector.connector_type + ); + + // If not OAuth, redirect to old connector edit page + if (!isOAuthConnector) { + router.push(`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`); + return; + } + + // Validate connector data + const connectorValidation = searchSourceConnector.safeParse(connector); + if (!connectorValidation.success) { + toast.error("Invalid connector data"); + return; + } + + setEditingConnector(connector); + // Load existing periodic sync settings + setPeriodicEnabled(connector.periodic_indexing_enabled); + setFrequencyMinutes(connector.indexing_frequency_minutes?.toString() || "1440"); + // Reset dates - user can set new ones for re-indexing + setStartDate(undefined); + setEndDate(undefined); + + // Update URL + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("view", "edit"); + url.searchParams.set("connectorId", connector.id.toString()); + window.history.pushState({ modal: true }, "", url.toString()); + }, [searchSpaceId, router]); + + // Handle saving connector changes + const handleSaveConnector = useCallback(async (refreshConnectors: () => void) => { + if (!editingConnector || !searchSpaceId) return; + + // Validate date range (skip for Google Drive which uses folder selection) + if (editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR") { + const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); + if (!dateRangeValidation.success) { + toast.error(dateRangeValidation.error.issues[0]?.message || "Invalid date range"); + return; + } + } + + // Validate frequency minutes if periodic is enabled + if (periodicEnabled) { + const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); + if (!frequencyValidation.success) { + toast.error("Invalid frequency value"); + return; + } + } + + setIsSaving(true); + try { + const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; + const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; + + // Update connector with periodic sync settings and config changes + const frequency = periodicEnabled ? parseInt(frequencyMinutes, 10) : null; + await updateConnector({ + id: editingConnector.id, + data: { + periodic_indexing_enabled: periodicEnabled, + indexing_frequency_minutes: frequency, + config: connectorConfig || editingConnector.config, + }, + }); + + // Re-index based on connector type + let indexingDescription = "Settings saved."; + if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") { + // Google Drive uses folder selection from config, not date ranges + const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as Array<{ id: string; name: string }> | undefined; + if (selectedFolders && selectedFolders.length > 0) { + const folderIds = selectedFolders.map((f) => f.id).join(","); + const folderNames = selectedFolders.map((f) => f.name).join(", "); + await indexConnector({ + connector_id: editingConnector.id, + queryParams: { + search_space_id: searchSpaceId, + folder_ids: folderIds, + folder_names: folderNames, + }, + }); + indexingDescription = `Re-indexing started for ${selectedFolders.length} folder(s).`; + } + } else if (startDateStr || endDateStr) { + // Other connectors use date ranges + await indexConnector({ + connector_id: editingConnector.id, + queryParams: { + search_space_id: searchSpaceId, + start_date: startDateStr, + end_date: endDateStr, + }, + }); + indexingDescription = "Re-indexing started with new date range."; + } + + toast.success(`${editingConnector.name} updated successfully`, { + description: periodicEnabled + ? `Periodic sync ${frequency ? `enabled every ${getFrequencyLabel(frequencyMinutes)}` : "enabled"}. ${indexingDescription}` + : indexingDescription, + }); + + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error saving connector:", error); + toast.error("Failed to save connector changes"); + } finally { + setIsSaving(false); + } + }, [editingConnector, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router, connectorConfig]); + + // Handle disconnecting connector + const handleDisconnectConnector = useCallback(async (refreshConnectors: () => void) => { + if (!editingConnector || !searchSpaceId) return; + + setIsDisconnecting(true); + try { + await deleteConnector({ + id: editingConnector.id, + }); + + toast.success(`${editingConnector.name} disconnected successfully`); + + // Update URL - the effect will handle closing the modal and clearing state + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); + + refreshConnectors(); + queryClient.invalidateQueries({ + queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), + }); + } catch (error) { + console.error("Error disconnecting connector:", error); + toast.error("Failed to disconnect connector"); + } finally { + setIsDisconnecting(false); + } + }, [editingConnector, searchSpaceId, deleteConnector, router]); + + // Handle going back from edit view + const handleBackFromEdit = useCallback(() => { + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", "all"); + url.searchParams.delete("view"); + url.searchParams.delete("connectorId"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); + + // Handle dialog open/close + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + + if (open) { + const url = new URL(window.location.href); + url.searchParams.set("modal", "connectors"); + url.searchParams.set("tab", activeTab); + window.history.pushState({ modal: true }, "", url.toString()); + } else { + const url = new URL(window.location.href); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); + url.searchParams.delete("connector"); + url.searchParams.delete("view"); + window.history.pushState({ modal: false }, "", url.toString()); + setIsScrolled(false); + setSearchQuery(""); + if (!isStartingIndexing && !isSaving && !isDisconnecting) { + setIndexingConfig(null); + setIndexingConnector(null); + setIndexingConnectorConfig(null); + setEditingConnector(null); + setConnectorConfig(null); + setStartDate(undefined); + setEndDate(undefined); + setPeriodicEnabled(false); + setFrequencyMinutes("1440"); + } + } + }, + [activeTab, isStartingIndexing, isDisconnecting, isSaving] + ); + + // Handle tab change + const handleTabChange = useCallback( + (value: string) => { + setActiveTab(value); + const url = new URL(window.location.href); + url.searchParams.set("tab", value); + window.history.replaceState({ modal: true }, "", url.toString()); + }, + [] + ); + + // Handle scroll + const handleScroll = useCallback((e: React.UIEvent) => { + setIsScrolled(e.currentTarget.scrollTop > 0); + }, []); + + return { + // State + isOpen, + activeTab, + connectingId, + isScrolled, + searchQuery, + indexingConfig, + indexingConnector, + indexingConnectorConfig, + editingConnector, + startDate, + endDate, + isStartingIndexing, + isSaving, + isDisconnecting, + periodicEnabled, + frequencyMinutes, + searchSpaceId, + allConnectors, + + // Setters + setSearchQuery, + setStartDate, + setEndDate, + setPeriodicEnabled, + setFrequencyMinutes, + + // Handlers + handleOpenChange, + handleTabChange, + handleScroll, + handleConnectOAuth, + handleStartIndexing, + handleSkipIndexing, + handleStartEdit, + handleSaveConnector, + handleDisconnectConnector, + handleBackFromEdit, + connectorConfig, + setConnectorConfig, + setIndexingConnectorConfig, + }; +}; + diff --git a/surfsense_web/components/assistant-ui/connector-popup/index.ts b/surfsense_web/components/assistant-ui/connector-popup/index.ts index 1aea4de95..1c5ebc471 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/index.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/index.ts @@ -2,17 +2,18 @@ export { ConnectorIndicator } from "../connector-popup"; // Sub-components (if needed for external use) -export { ConnectorCard } from "./connector-card"; -export { DateRangeSelector } from "./date-range-selector"; -export { PeriodicSyncConfig } from "./periodic-sync-config"; -export { IndexingConfigurationView } from "./indexing-configuration-view"; -export { ConnectorDialogHeader } from "./connector-dialog-header"; -export { AllConnectorsTab } from "./all-connectors-tab"; -export { ActiveConnectorsTab } from "./active-connectors-tab"; +export { ConnectorCard } from "./components/connector-card"; +export { DateRangeSelector } from "./components/date-range-selector"; +export { PeriodicSyncConfig } from "./components/periodic-sync-config"; +export { IndexingConfigurationView } from "./connector-configs/views/indexing-configuration-view"; +export { ConnectorEditView } from "./connector-configs/views/connector-edit-view"; +export { ConnectorDialogHeader } from "./components/connector-dialog-header"; +export { AllConnectorsTab } from "./tabs/all-connectors-tab"; +export { ActiveConnectorsTab } from "./tabs/active-connectors-tab"; // Constants and types -export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; -export type { IndexingConfigState } from "./connector-constants"; +export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./constants/connector-constants"; +export type { IndexingConfigState } from "./constants/connector-constants"; // Schemas and validation export { @@ -24,14 +25,14 @@ export { parseConnectorPopupQueryParams, parseOAuthAuthResponse, validateIndexingConfigState, -} from "./connector-popup.schemas"; +} from "./constants/connector-popup.schemas"; export type { ConnectorPopupQueryParams, OAuthAuthResponse, FrequencyMinutes, DateRange, -} from "./connector-popup.schemas"; +} from "./constants/connector-popup.schemas"; // Hooks -export { useConnectorDialog } from "./use-connector-dialog"; +export { useConnectorDialog } from "./hooks/use-connector-dialog"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx similarity index 97% rename from surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx rename to surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx index 43deba278..fd364c3d1 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx @@ -24,6 +24,7 @@ interface ActiveConnectorsTabProps { logsSummary: LogSummary | undefined; searchSpaceId: string; onTabChange: (value: string) => void; + onManage?: (connector: SearchSourceConnector) => void; } export const ActiveConnectorsTab: FC = ({ @@ -34,6 +35,7 @@ export const ActiveConnectorsTab: FC = ({ logsSummary, searchSpaceId, onTabChange, + onManage, }) => { const router = useRouter(); @@ -119,11 +121,7 @@ export const ActiveConnectorsTab: FC = ({ variant="outline" size="sm" className="h-8 text-[11px] px-3 rounded-lg font-medium" - onClick={() => - router.push( - `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` - ) - } + onClick={onManage ? () => onManage(connector) : undefined} disabled={isIndexing} > {isIndexing ? "Syncing..." : "Manage"} diff --git a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx similarity index 72% rename from surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx rename to surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx index 4dd056c90..b06c5f274 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/all-connectors-tab.tsx @@ -2,15 +2,18 @@ import { useRouter } from "next/navigation"; import { type FC } from "react"; -import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; -import { ConnectorCard } from "./connector-card"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants"; +import { ConnectorCard } from "../components/connector-card"; interface AllConnectorsTabProps { searchQuery: string; searchSpaceId: string; connectedTypes: Set; connectingId: string | null; + allConnectors: SearchSourceConnector[] | undefined; onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[0]) => void; + onManage?: (connector: SearchSourceConnector) => void; } export const AllConnectorsTab: FC = ({ @@ -18,7 +21,9 @@ export const AllConnectorsTab: FC = ({ searchSpaceId, connectedTypes, connectingId, + allConnectors, onConnectOAuth, + onManage, }) => { const router = useRouter(); @@ -49,6 +54,10 @@ export const AllConnectorsTab: FC = ({ {filteredOAuth.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); const isConnecting = connectingId === connector.id; + // Find the actual connector object if connected + const actualConnector = isConnected && allConnectors + ? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType) + : undefined; return ( = ({ isConnected={isConnected} isConnecting={isConnecting} onConnect={() => onConnectOAuth(connector)} - onManage={() => - router.push( - `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` - ) - } + onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined} /> ); })} @@ -83,6 +88,10 @@ export const AllConnectorsTab: FC = ({
{filteredOther.map((connector) => { const isConnected = connectedTypes.has(connector.connectorType); + // Find the actual connector object if connected + const actualConnector = isConnected && allConnectors + ? allConnectors.find((c: SearchSourceConnector) => c.connector_type === connector.connectorType) + : undefined; return ( = ({ `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` ) } - onManage={() => - router.push( - `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` - ) - } + onManage={actualConnector && onManage ? () => onManage(actualConnector) : undefined} /> ); })} diff --git a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts deleted file mode 100644 index 32a475300..000000000 --- a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { useAtomValue } from "jotai"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { indexConnectorMutationAtom, updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms"; -import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; -import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { authenticatedFetch } from "@/lib/auth-utils"; -import { queryClient } from "@/lib/query-client/client"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { format } from "date-fns"; -import type { SearchSourceConnector } from "@/contracts/types/connector.types"; -import { searchSourceConnector } from "@/contracts/types/connector.types"; -import { OAUTH_CONNECTORS } from "./connector-constants"; -import type { IndexingConfigState } from "./connector-constants"; -import { - parseConnectorPopupQueryParams, - parseOAuthAuthResponse, - validateIndexingConfigState, - frequencyMinutesSchema, - dateRangeSchema, -} from "./connector-popup.schemas"; - -export const useConnectorDialog = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); - const { data: allConnectors, refetch: refetchAllConnectors } = useAtomValue(connectorsAtom); - const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom); - const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom); - - const [isOpen, setIsOpen] = useState(false); - const [activeTab, setActiveTab] = useState("all"); - const [connectingId, setConnectingId] = useState(null); - const [isScrolled, setIsScrolled] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [indexingConfig, setIndexingConfig] = useState(null); - const [startDate, setStartDate] = useState(undefined); - const [endDate, setEndDate] = useState(undefined); - const [isStartingIndexing, setIsStartingIndexing] = useState(false); - const [periodicEnabled, setPeriodicEnabled] = useState(false); - const [frequencyMinutes, setFrequencyMinutes] = useState("1440"); - - // Helper function to get frequency label - const getFrequencyLabel = useCallback((minutes: string): string => { - switch (minutes) { - case "15": return "15 minutes"; - case "60": return "hour"; - case "360": return "6 hours"; - case "720": return "12 hours"; - case "1440": return "day"; - case "10080": return "week"; - default: return `${minutes} minutes`; - } - }, []); - - // Synchronize state with URL query params - useEffect(() => { - try { - const params = parseConnectorPopupQueryParams(searchParams); - - if (params.modal === "connectors") { - setIsOpen(true); - - if (params.tab === "active" || params.tab === "all") { - setActiveTab(params.tab); - } - - // Clear indexing config if view is not "configure" anymore - if (params.view !== "configure" && indexingConfig) { - setIndexingConfig(null); - } - - if (params.view === "configure" && params.connector && !indexingConfig) { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); - if (oauthConnector && allConnectors) { - const existingConnector = allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (existingConnector) { - // Validate connector data before setting state - const connectorValidation = searchSourceConnector.safeParse(existingConnector); - if (connectorValidation.success) { - const config = validateIndexingConfigState({ - connectorType: oauthConnector.connectorType, - connectorId: existingConnector.id, - connectorTitle: oauthConnector.title, - }); - setIndexingConfig(config); - } - } - } - } - } else { - setIsOpen(false); - // Clear indexing config when modal is closed - if (indexingConfig) { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - setIsScrolled(false); - setSearchQuery(""); - } - } - } catch (error) { - // Invalid query params - log but don't crash - console.warn("Invalid connector popup query params:", error); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchParams, allConnectors]); - - // Detect OAuth success and transition to config view - useEffect(() => { - try { - const params = parseConnectorPopupQueryParams(searchParams); - - if (params.success === "true" && params.connector && searchSpaceId && params.modal === "connectors") { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === params.connector); - if (oauthConnector) { - refetchAllConnectors().then((result) => { - if (!result.data) return; - - const newConnector = result.data.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (newConnector) { - // Validate connector data before setting state - const connectorValidation = searchSourceConnector.safeParse(newConnector); - if (connectorValidation.success) { - const config = validateIndexingConfigState({ - connectorType: oauthConnector.connectorType, - connectorId: newConnector.id, - connectorTitle: oauthConnector.title, - }); - setIndexingConfig(config); - setIsOpen(true); - const url = new URL(window.location.href); - url.searchParams.delete("success"); - 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"); - } - } - }); - } - } - } catch (error) { - // Invalid query params - log but don't crash - console.warn("Invalid connector popup query params in OAuth success handler:", error); - } - }, [searchParams, searchSpaceId, refetchAllConnectors]); - - // Handle OAuth connection - const handleConnectOAuth = useCallback( - async (connector: (typeof OAUTH_CONNECTORS)[0]) => { - if (!searchSpaceId || !connector.authEndpoint) return; - - // Set connecting state immediately to disable button and show spinner - setConnectingId(connector.id); - - try { - const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, - { method: "GET" } - ); - - if (!response.ok) { - throw new Error(`Failed to initiate ${connector.title} OAuth`); - } - - const data = await response.json(); - - // Validate OAuth response with Zod - const validatedData = parseOAuthAuthResponse(data); - - // Don't clear connectingId here - let the redirect happen with button still disabled - // The component will unmount on redirect anyway - window.location.href = validatedData.auth_url; - } catch (error) { - console.error(`Error connecting to ${connector.title}:`, error); - if (error instanceof Error && error.message.includes("Invalid auth URL")) { - toast.error(`Invalid response from ${connector.title} OAuth endpoint`); - } else { - toast.error(`Failed to connect to ${connector.title}`); - } - // Only clear connectingId on error so user can retry - setConnectingId(null); - } - }, - [searchSpaceId] - ); - - // Handle starting indexing - const handleStartIndexing = useCallback(async (refreshConnectors: () => void) => { - if (!indexingConfig || !searchSpaceId) return; - - // Validate date range - const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); - if (!dateRangeValidation.success) { - toast.error(dateRangeValidation.error.errors[0]?.message || "Invalid date range"); - return; - } - - // Validate frequency minutes if periodic is enabled - if (periodicEnabled) { - const frequencyValidation = frequencyMinutesSchema.safeParse(frequencyMinutes); - if (!frequencyValidation.success) { - toast.error("Invalid frequency value"); - return; - } - } - - setIsStartingIndexing(true); - try { - const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined; - const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined; - - if (periodicEnabled) { - const frequency = parseInt(frequencyMinutes, 10); - await updateConnector({ - id: indexingConfig.connectorId, - data: { - periodic_indexing_enabled: true, - indexing_frequency_minutes: frequency, - }, - }); - } - - await indexConnector({ - connector_id: indexingConfig.connectorId, - queryParams: { - search_space_id: searchSpaceId, - start_date: startDateStr, - end_date: endDateStr, - }, - }); - - toast.success(`${indexingConfig.connectorTitle} indexing started`, { - description: periodicEnabled - ? `Periodic sync enabled every ${getFrequencyLabel(frequencyMinutes)}.` - : "You can continue working while we sync your data.", - }); - - // Update URL - the effect will handle closing the modal and clearing state - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - router.replace(url.pathname + url.search, { scroll: false }); - - refreshConnectors(); - queryClient.invalidateQueries({ - queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), - }); - } catch (error) { - console.error("Error starting indexing:", error); - toast.error("Failed to start indexing"); - } finally { - setIsStartingIndexing(false); - } - }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router]); - - // Handle skipping indexing - const handleSkipIndexing = useCallback(() => { - // Update URL - the effect will handle closing the modal and clearing state - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - router.replace(url.pathname + url.search, { scroll: false }); - }, [router]); - - // Handle dialog open/close - const handleOpenChange = useCallback( - (open: boolean) => { - setIsOpen(open); - - if (open) { - const url = new URL(window.location.href); - url.searchParams.set("modal", "connectors"); - url.searchParams.set("tab", activeTab); - window.history.pushState({ modal: true }, "", url.toString()); - } else { - const url = new URL(window.location.href); - url.searchParams.delete("modal"); - url.searchParams.delete("tab"); - url.searchParams.delete("success"); - url.searchParams.delete("connector"); - url.searchParams.delete("view"); - window.history.pushState({ modal: false }, "", url.toString()); - setIsScrolled(false); - setSearchQuery(""); - if (!isStartingIndexing) { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - } - } - }, - [activeTab, isStartingIndexing] - ); - - // Handle tab change - const handleTabChange = useCallback( - (value: string) => { - setActiveTab(value); - const url = new URL(window.location.href); - url.searchParams.set("tab", value); - window.history.replaceState({ modal: true }, "", url.toString()); - }, - [] - ); - - // Handle scroll - const handleScroll = useCallback((e: React.UIEvent) => { - setIsScrolled(e.currentTarget.scrollTop > 0); - }, []); - - return { - // State - isOpen, - activeTab, - connectingId, - isScrolled, - searchQuery, - indexingConfig, - startDate, - endDate, - isStartingIndexing, - periodicEnabled, - frequencyMinutes, - searchSpaceId, - allConnectors, - - // Setters - setSearchQuery, - setStartDate, - setEndDate, - setPeriodicEnabled, - setFrequencyMinutes, - - // Handlers - handleOpenChange, - handleTabChange, - handleScroll, - handleConnectOAuth, - handleStartIndexing, - handleSkipIndexing, - }; -}; - diff --git a/surfsense_web/components/connectors/google-drive-folder-tree.tsx b/surfsense_web/components/connectors/google-drive-folder-tree.tsx index cec207b2a..eed18f173 100644 --- a/surfsense_web/components/connectors/google-drive-folder-tree.tsx +++ b/surfsense_web/components/connectors/google-drive-folder-tree.tsx @@ -14,7 +14,6 @@ import { Presentation, } from "lucide-react"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; @@ -207,63 +206,74 @@ export function GoogleDriveFolderTree({ const childFolders = children?.filter((c) => c.isFolder) || []; const childFiles = children?.filter((c) => !c.isFolder) || []; + const indentSize = 0.75; // Smaller indent for mobile + return ( -
+
{isFolder ? ( - { e.stopPropagation(); toggleFolder(item); }} + aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`} > {isLoading ? ( - + ) : isExpanded ? ( - + ) : ( - + )} - + ) : ( - + )} {isFolder && ( toggleFolderSelection(item.id, item.name)} - className="shrink-0" + className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4" onClick={(e) => e.stopPropagation()} /> )} -
+
{isFolder ? ( isExpanded ? ( - + ) : ( - + ) ) : ( - getFileIcon(item.mimeType, "h-4 w-4") + getFileIcon(item.mimeType, "h-3 w-3 sm:h-4 sm:w-4") )}
- isFolder && toggleFolder(item)} - > - {item.name} - + {isFolder ? ( + + ) : ( + + {item.name} + + )}
{isExpanded && isFolder && children && ( @@ -272,7 +282,7 @@ export function GoogleDriveFolderTree({ {childFiles.map((child) => renderItem(child, level + 1))} {children.length === 0 && ( -
Empty folder
+
Empty folder
)}
)} @@ -282,25 +292,29 @@ export function GoogleDriveFolderTree({ return (
- -
-
-
+ +
+
+
toggleFolderSelection("root", "My Drive")} - className="shrink-0" + className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4" /> - - toggleFolderSelection("root", "My Drive")}> + +
{isLoadingRoot && ( -
- +
+
)} @@ -309,7 +323,7 @@ export function GoogleDriveFolderTree({
{!isLoadingRoot && rootItems.length === 0 && ( -
+
No files or folders found in your Google Drive
)} diff --git a/surfsense_web/components/ui/switch.tsx b/surfsense_web/components/ui/switch.tsx index b64b32b73..de2c35fc0 100644 --- a/surfsense_web/components/ui/switch.tsx +++ b/surfsense_web/components/ui/switch.tsx @@ -11,7 +11,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => (