diff --git a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx index 323fa34e7..43deba278 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/active-connectors-tab.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import type { LogSummary, LogActiveTask } from "@/contracts/types/log.types"; import { cn } from "@/lib/utils"; import { TabsContent, @@ -20,7 +21,7 @@ interface ActiveConnectorsTabProps { activeDocumentTypes: Array<[string, number]>; connectors: SearchSourceConnector[]; indexingConnectorIds: Set; - logsSummary: any; + logsSummary: LogSummary | undefined; searchSpaceId: string; onTabChange: (value: string) => void; } @@ -67,7 +68,7 @@ export const ActiveConnectorsTab: FC = ({ {connectors.map((connector) => { const isIndexing = indexingConnectorIds.has(connector.id); const activeTask = logsSummary?.active_tasks?.find( - (task: any) => + (task: LogActiveTask) => task.source?.includes(`connector_${connector.id}`) || task.source?.includes(`connector-${connector.id}`) ); diff --git a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx index e2f735b13..4dd056c90 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/all-connectors-tab.tsx @@ -1,10 +1,7 @@ "use client"; -import { ChevronRight } from "lucide-react"; -import Link from "next/link"; import { useRouter } from "next/navigation"; import { type FC } from "react"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; import { ConnectorCard } from "./connector-card"; @@ -88,32 +85,24 @@ export const AllConnectorsTab: FC = ({ const isConnected = connectedTypes.has(connector.connectorType); return ( - -
- {getConnectorIcon(connector.connectorType, "size-6")} -
-
-
- - {connector.title} - - {isConnected && ( - - )} -
-

- {connector.description} -

-
- - + id={connector.id} + title={connector.title} + description={connector.description} + connectorType={connector.connectorType} + isConnected={isConnected} + onConnect={() => + router.push( + `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` + ) + } + onManage={() => + router.push( + `/dashboard/${searchSpaceId}/connectors/add/${connector.id}` + ) + } + /> ); })} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx index d1f79b16d..1e5871579 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-card.tsx @@ -34,12 +34,6 @@ export const ConnectorCard: FC = ({
{title} - {isConnected && ( - - )}

{isConnected ? "Connected" : description} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts index 65d5bd516..a2750e133 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-constants.ts @@ -2,6 +2,13 @@ import { EnumConnectorName } from "@/contracts/enums/connector"; // OAuth Connectors (Quick Connect) export const OAUTH_CONNECTORS = [ + { + id: "google-drive-connector", + title: "Google Drive", + description: "Search your Drive files", + connectorType: EnumConnectorName.GOOGLE_DRIVE_CONNECTOR, + authEndpoint: "/api/v1/auth/google/drive/connector/add/", + }, { id: "google-gmail-connector", title: "Gmail", @@ -125,10 +132,6 @@ export const OTHER_CONNECTORS = [ }, ] as const; -// Type for the indexing configuration state -export interface IndexingConfigState { - connectorType: string; - connectorId: number; - connectorTitle: string; -} +// 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/connector-popup.schemas.ts b/surfsense_web/components/assistant-ui/connector-popup/connector-popup.schemas.ts new file mode 100644 index 000000000..118625e57 --- /dev/null +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-popup.schemas.ts @@ -0,0 +1,108 @@ +import { z } from "zod"; +import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types"; + +/** + * Schema for URL query parameters used by the connector popup + */ +export const connectorPopupQueryParamsSchema = z.object({ + modal: z.enum(["connectors"]).optional(), + tab: z.enum(["all", "active"]).optional(), + view: z.enum(["configure"]).optional(), + connector: z.string().optional(), + success: z.enum(["true", "false"]).optional(), +}); + +export type ConnectorPopupQueryParams = z.infer; + +/** + * Schema for OAuth API response (auth_url) + */ +export const oauthAuthResponseSchema = z.object({ + auth_url: z.string().url("Invalid auth URL format"), +}); + +export type OAuthAuthResponse = z.infer; + +/** + * Schema for IndexingConfigState + */ +export const indexingConfigStateSchema = z.object({ + connectorType: searchSourceConnectorTypeEnum, + connectorId: z.number().int().positive("Connector ID must be a positive integer"), + connectorTitle: z.string().min(1, "Connector title is required"), +}); + +export type IndexingConfigState = z.infer; + +/** + * Schema for frequency minutes (must be one of the allowed values) + */ +export const frequencyMinutesSchema = z.enum(["15", "60", "360", "720", "1440", "10080"], { + errorMap: () => ({ message: "Invalid frequency value" }), +}); + +export type FrequencyMinutes = z.infer; + +/** + * Schema for date range validation + */ +export const dateRangeSchema = z + .object({ + startDate: z.date().optional(), + endDate: z.date().optional(), + }) + .refine( + (data) => { + if (data.startDate && data.endDate) { + return data.startDate <= data.endDate; + } + return true; + }, + { + message: "Start date must be before or equal to end date", + path: ["endDate"], + } + ); + +export type DateRange = z.infer; + +/** + * Schema for connector ID validation (used in URL params) + */ +export const connectorIdSchema = z.string().min(1, "Connector ID is required"); + +/** + * Helper function to safely parse query params + */ +export function parseConnectorPopupQueryParams( + params: URLSearchParams | Record +): ConnectorPopupQueryParams { + const obj: Record = {}; + + if (params instanceof URLSearchParams) { + params.forEach((value, key) => { + obj[key] = value || undefined; + }); + } else { + Object.entries(params).forEach(([key, value]) => { + obj[key] = value || undefined; + }); + } + + return connectorPopupQueryParamsSchema.parse(obj); +} + +/** + * Helper function to safely parse OAuth response + */ +export function parseOAuthAuthResponse(data: unknown): OAuthAuthResponse { + return oauthAuthResponseSchema.parse(data); +} + +/** + * Helper function to validate indexing config state + */ +export function validateIndexingConfigState(data: unknown): IndexingConfigState { + return indexingConfigStateSchema.parse(data); +} + diff --git a/surfsense_web/components/assistant-ui/connector-popup/index.ts b/surfsense_web/components/assistant-ui/connector-popup/index.ts index da1d4639e..1aea4de95 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/index.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/index.ts @@ -14,6 +14,24 @@ export { ActiveConnectorsTab } from "./active-connectors-tab"; export { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "./connector-constants"; export type { IndexingConfigState } from "./connector-constants"; +// Schemas and validation +export { + connectorPopupQueryParamsSchema, + oauthAuthResponseSchema, + indexingConfigStateSchema, + frequencyMinutesSchema, + dateRangeSchema, + parseConnectorPopupQueryParams, + parseOAuthAuthResponse, + validateIndexingConfigState, +} from "./connector-popup.schemas"; +export type { + ConnectorPopupQueryParams, + OAuthAuthResponse, + FrequencyMinutes, + DateRange, +} from "./connector-popup.schemas"; + // Hooks export { useConnectorDialog } from "./use-connector-dialog"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx index cb9c3b66b..3135c634b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/indexing-configuration-view.tsx @@ -1,9 +1,10 @@ "use client"; import { ArrowLeft, Check, Loader2 } from "lucide-react"; -import { type FC } from "react"; +import { type FC, useState, useCallback, useRef, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { cn } from "@/lib/utils"; import type { IndexingConfigState } from "./connector-constants"; import { DateRangeSelector } from "./date-range-selector"; import { PeriodicSyncConfig } from "./periodic-sync-config"; @@ -37,10 +38,49 @@ export const IndexingConfigurationView: FC = ({ onStartIndexing, onSkip, }) => { + const [isScrolled, setIsScrolled] = useState(false); + const [hasMoreContent, setHasMoreContent] = 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]); + return (

{/* Fixed Header */} -
+
{/* Back button */}
{/* Scrollable Content */} -
-
- +
+
+
+ - + - {/* Info box */} -
-
- {getConnectorIcon(config.connectorType, "size-4")} -
-
-

Indexing runs in the background

-

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

+ {/* Info box */} +
+
+ {getConnectorIcon(config.connectorType, "size-4")} +
+
+

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 */} -
+
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 index 5a309c2ce..32a475300 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/use-connector-dialog.ts @@ -10,8 +10,16 @@ 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(); @@ -48,65 +56,101 @@ export const useConnectorDialog = () => { // Synchronize state with URL query params useEffect(() => { - const modalParam = searchParams.get("modal"); - const tabParam = searchParams.get("tab"); - const viewParam = searchParams.get("view"); - const connectorParam = searchParams.get("connector"); - - if (modalParam === "connectors") { - if (!isOpen) setIsOpen(true); + try { + const params = parseConnectorPopupQueryParams(searchParams); - if (tabParam === "active" || tabParam === "all") { - if (activeTab !== tabParam) setActiveTab(tabParam); - } - - if (viewParam === "configure" && connectorParam && !indexingConfig) { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); - if (oauthConnector && allConnectors) { - const existingConnector = allConnectors.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (existingConnector) { - setIndexingConfig({ - connectorType: oauthConnector.connectorType, - connectorId: existingConnector.id, - connectorTitle: oauthConnector.title, - }); + 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(""); + } } - } else { - if (isOpen) setIsOpen(false); + } catch (error) { + // Invalid query params - log but don't crash + console.warn("Invalid connector popup query params:", error); } - }, [searchParams, isOpen, activeTab, indexingConfig, allConnectors]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, allConnectors]); // Detect OAuth success and transition to config view useEffect(() => { - const success = searchParams.get("success"); - const connectorParam = searchParams.get("connector"); - const modalParam = searchParams.get("modal"); - - if (success === "true" && connectorParam && searchSpaceId && modalParam === "connectors") { - const oauthConnector = OAUTH_CONNECTORS.find(c => c.id === connectorParam); - if (oauthConnector) { - refetchAllConnectors().then((result) => { - const newConnector = result.data?.find( - (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType - ); - if (newConnector) { - setIndexingConfig({ - connectorType: oauthConnector.connectorType, - connectorId: newConnector.id, - connectorTitle: oauthConnector.title, - }); - setIsOpen(true); - const url = new URL(window.location.href); - url.searchParams.delete("success"); - url.searchParams.set("view", "configure"); - window.history.replaceState({}, "", url.toString()); - } - }); + 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]); @@ -115,8 +159,10 @@ export const useConnectorDialog = () => { 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 { - setConnectingId(connector.id); const response = await authenticatedFetch( `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, { method: "GET" } @@ -127,11 +173,21 @@ export const useConnectorDialog = () => { } const data = await response.json(); - window.location.href = data.auth_url; + + // 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); - toast.error(`Failed to connect to ${connector.title}`); - } finally { + 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); } }, @@ -142,6 +198,22 @@ export const useConnectorDialog = () => { 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; @@ -173,18 +245,14 @@ export const useConnectorDialog = () => { : "You can continue working while we sync your data.", }); - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - + // Update URL - the effect will handle closing the modal and clearing state const url = new URL(window.location.href); - url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); url.searchParams.delete("connector"); - url.searchParams.set("tab", "active"); - window.history.replaceState({}, "", url.toString()); - setActiveTab("active"); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); refreshConnectors(); queryClient.invalidateQueries({ @@ -196,21 +264,19 @@ export const useConnectorDialog = () => { } finally { setIsStartingIndexing(false); } - }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel]); + }, [indexingConfig, searchSpaceId, startDate, endDate, indexConnector, updateConnector, periodicEnabled, frequencyMinutes, getFrequencyLabel, router]); // Handle skipping indexing const handleSkipIndexing = useCallback(() => { - setIndexingConfig(null); - setStartDate(undefined); - setEndDate(undefined); - setPeriodicEnabled(false); - setFrequencyMinutes("1440"); - + // Update URL - the effect will handle closing the modal and clearing state const url = new URL(window.location.href); - url.searchParams.delete("view"); + url.searchParams.delete("modal"); + url.searchParams.delete("tab"); + url.searchParams.delete("success"); url.searchParams.delete("connector"); - window.history.replaceState({}, "", url.toString()); - }, []); + url.searchParams.delete("view"); + router.replace(url.pathname + url.search, { scroll: false }); + }, [router]); // Handle dialog open/close const handleOpenChange = useCallback(