-
- {config.connectorTitle} Connected!
-
+
+
+ {getConnectorTypeDisplay(connector?.connector_type || "")} Connected !
+ {" "}
+
+ {getConnectorDisplayName(connector?.name || "")}
+
+
Configure when to start syncing your data
diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts
index 65456689c..a1b303163 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts
+++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-popup.schemas.ts
@@ -7,11 +7,12 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
export const connectorPopupQueryParamsSchema = z.object({
modal: z.enum(["connectors"]).optional(),
tab: z.enum(["all", "active"]).optional(),
- view: z.enum(["configure", "edit", "connect", "youtube"]).optional(),
+ view: z.enum(["configure", "edit", "connect", "youtube", "accounts"]).optional(),
connector: z.string().optional(),
connectorId: z.string().optional(),
connectorType: z.string().optional(),
success: z.enum(["true", "false"]).optional(),
+ error: z.string().optional(),
});
export type ConnectorPopupQueryParams = z.infer
;
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 8ddaa973a..2c8248255 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
@@ -66,6 +66,12 @@ export const useConnectorDialog = () => {
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const isCreatingConnectorRef = useRef(false);
+ // Accounts list view state (for OAuth connectors with multiple accounts)
+ const [viewingAccountsType, setViewingAccountsType] = useState<{
+ connectorType: string;
+ connectorTitle: string;
+ } | null>(null);
+
// Helper function to get frequency label
const getFrequencyLabel = useCallback((minutes: string): string => {
switch (minutes) {
@@ -114,24 +120,50 @@ export const useConnectorDialog = () => {
setConnectingConnectorType(null);
}
+ // Clear viewing accounts type if view is not "accounts" anymore
+ if (params.view !== "accounts" && viewingAccountsType) {
+ setViewingAccountsType(null);
+ }
+
// Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType);
}
+ // Handle accounts view
+ if (params.view === "accounts" && params.connectorType && !viewingAccountsType) {
+ const oauthConnector = OAUTH_CONNECTORS.find(
+ (c) => c.connectorType === params.connectorType
+ );
+ if (oauthConnector) {
+ setViewingAccountsType({
+ connectorType: oauthConnector.connectorType,
+ connectorTitle: oauthConnector.title,
+ });
+ }
+ }
+
// Handle YouTube view
if (params.view === "youtube") {
// YouTube view is active - no additional state needed
}
- if (params.view === "configure" && params.connector && !indexingConfig) {
+ // Handle configure view (for page refresh support)
+ if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
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 (oauthConnector) {
+ let existingConnector: SearchSourceConnector | undefined;
+ if (params.connectorId) {
+ const connectorId = parseInt(params.connectorId, 10);
+ existingConnector = allConnectors.find(
+ (c: SearchSourceConnector) => c.id === connectorId
+ );
+ } else {
+ 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({
@@ -200,6 +232,10 @@ export const useConnectorDialog = () => {
if (connectingConnectorType) {
setConnectingConnectorType(null);
}
+ // Clear viewing accounts type when modal is closed
+ if (viewingAccountsType) {
+ setViewingAccountsType(null);
+ }
// Clear YouTube view when modal is closed (handled by view param check)
}
} catch (error) {
@@ -207,13 +243,48 @@ export const useConnectorDialog = () => {
console.warn("Invalid connector popup query params:", error);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [searchParams, allConnectors, editingConnector, indexingConfig, connectingConnectorType]);
+ }, [
+ searchParams,
+ allConnectors,
+ editingConnector,
+ indexingConfig,
+ connectingConnectorType,
+ viewingAccountsType,
+ ]);
- // Detect OAuth success and transition to config view
+ // Detect OAuth success / Failure and transition to config view
useEffect(() => {
try {
const params = parseConnectorPopupQueryParams(searchParams);
+ // Handle OAuth errors (e.g., duplicate account)
+ if (params.error && params.modal === "connectors") {
+ const oauthConnector = params.connector
+ ? OAUTH_CONNECTORS.find((c) => c.id === params.connector)
+ : null;
+ const connectorName = oauthConnector?.title || "connector";
+
+ if (params.error === "duplicate_account") {
+ toast.error(`This ${connectorName} account is already connected`, {
+ description: "Please use a different account or manage the existing connection.",
+ });
+ } else {
+ toast.error(`Failed to connect ${connectorName}`, {
+ description: params.error.replace(/_/g, " "),
+ });
+ }
+
+ // Clean up error params from URL
+ const url = new URL(window.location.href);
+ url.searchParams.delete("error");
+ url.searchParams.delete("connector");
+ window.history.replaceState({}, "", url.toString());
+
+ // Open the popup to show the connectors
+ setIsOpen(true);
+ return;
+ }
+
if (
params.success === "true" &&
params.connector &&
@@ -225,11 +296,17 @@ export const useConnectorDialog = () => {
refetchAllConnectors().then((result) => {
if (!result.data) return;
- const newConnector = result.data.find(
- (c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
- );
+ let newConnector: SearchSourceConnector | undefined;
+ if (params.connectorId) {
+ const connectorId = parseInt(params.connectorId, 10);
+ newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
+ } else {
+ 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({
@@ -243,6 +320,7 @@ export const useConnectorDialog = () => {
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 {
@@ -632,6 +710,38 @@ export const useConnectorDialog = () => {
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
+ // Handle viewing accounts list for OAuth connector type
+ const handleViewAccountsList = useCallback(
+ (connectorType: string, connectorTitle: string) => {
+ if (!searchSpaceId) return;
+
+ setViewingAccountsType({
+ connectorType,
+ connectorTitle,
+ });
+
+ // Update URL to show accounts view, preserving current tab
+ const url = new URL(window.location.href);
+ url.searchParams.set("modal", "connectors");
+ url.searchParams.set("view", "accounts");
+ url.searchParams.set("connectorType", connectorType);
+ // Keep the current tab in URL so we can go back to it
+ window.history.pushState({ modal: true }, "", url.toString());
+ },
+ [searchSpaceId]
+ );
+
+ // Handle going back from accounts list view
+ const handleBackFromAccountsList = useCallback(() => {
+ setViewingAccountsType(null);
+ const url = new URL(window.location.href);
+ url.searchParams.set("modal", "connectors");
+ // Keep the current tab (don't change it) - just remove view-specific params
+ url.searchParams.delete("view");
+ url.searchParams.delete("connectorType");
+ router.replace(url.pathname + url.search, { scroll: false });
+ }, [router]);
+
// Handle starting indexing
const handleStartIndexing = useCallback(
async (refreshConnectors: () => void) => {
@@ -1081,6 +1191,7 @@ export const useConnectorDialog = () => {
setConnectorName(null);
setConnectorConfig(null);
setConnectingConnectorType(null);
+ setViewingAccountsType(null);
setStartDate(undefined);
setEndDate(undefined);
setPeriodicEnabled(false);
@@ -1126,6 +1237,7 @@ export const useConnectorDialog = () => {
frequencyMinutes,
searchSpaceId,
allConnectors,
+ viewingAccountsType,
// Setters
setSearchQuery,
@@ -1152,6 +1264,8 @@ export const useConnectorDialog = () => {
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
+ handleViewAccountsList,
+ handleBackFromAccountsList,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
diff --git a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
index 3dd4fd1d0..7f1bd28f0 100644
--- a/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
+++ b/surfsense_web/components/assistant-ui/connector-popup/tabs/active-connectors-tab.tsx
@@ -11,6 +11,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
+import { OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
interface ActiveConnectorsTabProps {
@@ -24,6 +25,7 @@ interface ActiveConnectorsTabProps {
searchSpaceId: string;
onTabChange: (value: string) => void;
onManage?: (connector: SearchSourceConnector) => void;
+ onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
}
export const ActiveConnectorsTab: FC = ({
@@ -36,6 +38,7 @@ export const ActiveConnectorsTab: FC = ({
searchSpaceId,
onTabChange,
onManage,
+ onViewAccountsList,
}) => {
const router = useRouter();
@@ -71,38 +74,26 @@ export const ActiveConnectorsTab: FC = ({
const minutesAgo = differenceInMinutes(now, date);
const daysAgo = differenceInDays(now, date);
- // Just now (within last minute)
- if (minutesAgo < 1) {
- return "Just now";
- }
-
- // X minutes ago (less than 1 hour)
- if (minutesAgo < 60) {
- return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
- }
-
- // Today at [time]
- if (isToday(date)) {
- return `Today at ${format(date, "h:mm a")}`;
- }
-
- // Yesterday at [time]
- if (isYesterday(date)) {
- return `Yesterday at ${format(date, "h:mm a")}`;
- }
-
- // X days ago (less than 7 days)
- if (daysAgo < 7) {
- return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
- }
-
- // Full date for older entries
+ if (minutesAgo < 1) return "Just now";
+ if (minutesAgo < 60) return `${minutesAgo} ${minutesAgo === 1 ? "minute" : "minutes"} ago`;
+ if (isToday(date)) return `Today at ${format(date, "h:mm a")}`;
+ if (isYesterday(date)) return `Yesterday at ${format(date, "h:mm a")}`;
+ if (daysAgo < 7) return `${daysAgo} ${daysAgo === 1 ? "day" : "days"} ago`;
return format(date, "MMM d, yyyy");
};
- // Document types that should be shown as cards (not from connectors)
- // These are: EXTENSION (browser extension), FILE (uploaded files), NOTE (editor notes),
- // YOUTUBE_VIDEO (YouTube videos), and CRAWLED_URL (web pages - shown separately even though it can come from WEBCRAWLER_CONNECTOR)
+ // Get most recent last indexed date from a list of connectors
+ const getMostRecentLastIndexed = (
+ connectorsList: SearchSourceConnector[]
+ ): string | undefined => {
+ return connectorsList.reduce((latest, c) => {
+ if (!c.last_indexed_at) return latest;
+ if (!latest) return c.last_indexed_at;
+ return new Date(c.last_indexed_at) > new Date(latest) ? c.last_indexed_at : latest;
+ }, undefined);
+ };
+
+ // Document types that should be shown as standalone cards (not from connectors)
const standaloneDocumentTypes = ["EXTENSION", "FILE", "NOTE", "YOUTUBE_VIDEO", "CRAWLED_URL"];
// Filter to only show standalone document types that have documents (count > 0)
@@ -118,8 +109,54 @@ export const ActiveConnectorsTab: FC = ({
return doc.label.toLowerCase().includes(searchQuery.toLowerCase());
});
- // Filter connectors based on search query
- const filteredConnectors = connectors.filter((connector) => {
+ // Get OAuth connector types set for quick lookup
+ const oauthConnectorTypes = new Set(OAUTH_CONNECTORS.map((c) => c.connectorType));
+
+ // Separate OAuth and non-OAuth connectors
+ const oauthConnectors = connectors.filter((c) => oauthConnectorTypes.has(c.connector_type));
+ const nonOauthConnectors = connectors.filter((c) => !oauthConnectorTypes.has(c.connector_type));
+
+ // Group OAuth connectors by type
+ const oauthConnectorsByType = oauthConnectors.reduce(
+ (acc, connector) => {
+ const type = connector.connector_type;
+ if (!acc[type]) {
+ acc[type] = [];
+ }
+ acc[type].push(connector);
+ return acc;
+ },
+ {} as Record
+ );
+
+ // Get display info for OAuth connector type
+ const getOAuthConnectorTypeInfo = (connectorType: string) => {
+ const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType);
+ return {
+ title:
+ oauthConnector?.title ||
+ connectorType
+ .replace(/_/g, " ")
+ .replace(/connector/gi, "")
+ .trim(),
+ };
+ };
+
+ // Filter OAuth connector types based on search query
+ const filteredOAuthConnectorTypes = Object.entries(oauthConnectorsByType).filter(
+ ([connectorType]) => {
+ if (!searchQuery) return true;
+ const searchLower = searchQuery.toLowerCase();
+ const { title } = getOAuthConnectorTypeInfo(connectorType);
+ return (
+ title.toLowerCase().includes(searchLower) ||
+ connectorType.toLowerCase().includes(searchLower)
+ );
+ }
+ );
+
+ // Filter non-OAuth connectors based on search query
+ const filteredNonOAuthConnectors = nonOauthConnectors.filter((connector) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
@@ -128,18 +165,97 @@ export const ActiveConnectorsTab: FC = ({
);
});
+ const hasActiveConnectors =
+ filteredOAuthConnectorTypes.length > 0 || filteredNonOAuthConnectors.length > 0;
+
return (
{hasSources ? (
{/* Active Connectors Section */}
- {filteredConnectors.length > 0 && (
+ {hasActiveConnectors && (
Active Connectors
- {filteredConnectors.map((connector) => {
+ {/* OAuth Connectors - Grouped by Type */}
+ {filteredOAuthConnectorTypes.map(([connectorType, typeConnectors]) => {
+ const { title } = getOAuthConnectorTypeInfo(connectorType);
+ const isAnyIndexing = typeConnectors.some((c: SearchSourceConnector) =>
+ indexingConnectorIds.has(c.id)
+ );
+ const documentCount = getDocumentCountForConnector(
+ connectorType,
+ documentTypeCounts
+ );
+ const accountCount = typeConnectors.length;
+ const mostRecentLastIndexed = getMostRecentLastIndexed(typeConnectors);
+
+ const handleManageClick = () => {
+ if (onViewAccountsList) {
+ onViewAccountsList(connectorType, title);
+ } else if (onManage && typeConnectors[0]) {
+ onManage(typeConnectors[0]);
+ }
+ };
+
+ return (
+
+
+ {getConnectorIcon(connectorType, "size-6")}
+
+
+
{title}
+ {isAnyIndexing ? (
+
+
+ Indexing...
+
+ ) : (
+
+ {mostRecentLastIndexed
+ ? `Last indexed: ${formatLastIndexedDate(mostRecentLastIndexed)}`
+ : "Never indexed"}
+
+ )}
+
+ {formatDocumentCount(documentCount)}
+ •
+
+ {accountCount} {accountCount === 1 ? "Account" : "Accounts"}
+
+
+
+
+
+ );
+ })}
+
+ {/* Non-OAuth Connectors - Individual Cards */}
+ {filteredNonOAuthConnectors.map((connector) => {
const isIndexing = indexingConnectorIds.has(connector.id);
const activeTask = logsSummary?.active_tasks?.find(
(task: LogActiveTask) => task.connector_id === connector.id
@@ -161,7 +277,7 @@ export const ActiveConnectorsTab: FC
= ({
>
= ({