+
+ {(showPasswordSection || hasConnectors) && (
+
@@ -268,23 +316,45 @@ export function UserSettingsModal({
Settings
-
- setActiveSection("password")}
- >
- Password
-
-
+ {showPasswordSection && (
+
+ setActiveSection("password")}
+ >
+ Password
+
+
+ )}
+ {hasConnectors && (
+
+ setActiveSection("connectors")}
+ >
+ Connectors
+
+
+ )}
)}
-
+
{activeSection === "settings" && (
@@ -488,6 +558,194 @@ export function UserSettingsModal({
)}
+ {activeSection === "connectors" && (
+
+
+
+ Connected Services
+
+
+ Manage your connected services to search across all your
+ content.
+
+
+ {/* Indexed Connectors Section */}
+ {ccPairs && ccPairs.length > 0 && (
+
+
+ Indexed Connectors
+
+ {(() => {
+ // Group connectors by source
+ const groupedConnectors = ccPairs.reduce(
+ (acc, ccPair) => {
+ const source = ccPair.source;
+ if (!acc[source]) {
+ acc[source] = {
+ source,
+ count: 0,
+ hasSuccessfulRun: false,
+ };
+ }
+ acc[source]!.count++;
+ if (ccPair.has_successful_run) {
+ acc[source]!.hasSuccessfulRun = true;
+ }
+ return acc;
+ },
+ {} as Record<
+ string,
+ {
+ source: ValidSources;
+ count: number;
+ hasSuccessfulRun: boolean;
+ }
+ >
+ );
+
+ // Helper function to format source names
+ const formatSourceName = (source: string) => {
+ return source
+ .split("_")
+ .map(
+ (word) =>
+ word.charAt(0).toUpperCase() + word.slice(1)
+ )
+ .join(" ");
+ };
+
+ return Object.values(groupedConnectors).map((group) => (
+
+
+
+
+
+ {formatSourceName(group.source)}
+
+
+ {group.count > 1
+ ? `${group.count} connectors`
+ : "Connected"}
+
+
+
+
+ Active
+
+
+ ));
+ })()}
+
+ )}
+
+ {/* Federated Search Section */}
+ {federatedConnectors && federatedConnectors.length > 0 && (
+
+
+ Federated Connectors
+
+ {(() => {
+ // Helper function to format source names
+ const formatSourceName = (source: string) => {
+ return source
+ .split("_")
+ .map(
+ (word) =>
+ word.charAt(0).toUpperCase() + word.slice(1)
+ )
+ .join(" ");
+ };
+
+ return federatedConnectors.map((connector) => {
+ const sourceMetadata = getSourceMetadata(
+ connector.source as ValidSources
+ );
+ return (
+
+
+
+
+
+ {formatSourceName(
+ sourceMetadata.displayName
+ )}
+
+
+ {connector.has_oauth_token
+ ? "Connected"
+ : "Not connected"}
+
+
+
+
+ {connector.has_oauth_token ? (
+
+ handleDisconnectOAuth(
+ connector.federated_connector_id
+ )
+ }
+ disabled={
+ isDisconnecting ===
+ connector.federated_connector_id
+ }
+ >
+ {isDisconnecting ===
+ connector.federated_connector_id
+ ? "Disconnecting..."
+ : "Disconnect"}
+
+ ) : (
+ {
+ if (connector.authorize_url) {
+ handleConnectOAuth(
+ connector.authorize_url
+ );
+ }
+ }}
+ disabled={!connector.authorize_url}
+ >
+
+ Connect
+
+ )}
+
+
+ );
+ });
+ })()}
+
+ )}
+
+ {!hasConnectors && (
+
+
+ No connectors available.
+
+
+ )}
+
+
+ )}
diff --git a/web/src/app/federated/oauth/callback/page.tsx b/web/src/app/federated/oauth/callback/page.tsx
new file mode 100644
index 0000000000..f37f49e449
--- /dev/null
+++ b/web/src/app/federated/oauth/callback/page.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { CheckmarkIcon, TriangleAlertIcon } from "@/components/icons/icons";
+import CardSection from "@/components/admin/CardSection";
+import { Button } from "@/components/ui/button";
+import { getSourceDisplayName } from "@/lib/sources";
+
+export default function FederatedOAuthCallbackPage() {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [statusMessage, setStatusMessage] = useState("Processing...");
+ const [statusDetails, setStatusDetails] = useState(
+ "Please wait while we complete the setup."
+ );
+ const [isError, setIsError] = useState(false);
+ const [isSuccess, setIsSuccess] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+
+ // Extract query parameters
+ const code = searchParams?.get("code");
+ const state = searchParams?.get("state");
+ const error = searchParams?.get("error");
+ const errorDescription = searchParams?.get("error_description");
+
+ // Auto-redirect for success cases
+ useEffect(() => {
+ if (isSuccess) {
+ const timer = setTimeout(() => {
+ router.push("/chat");
+ }, 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [isSuccess, router]);
+
+ useEffect(() => {
+ const handleOAuthCallback = async () => {
+ // Handle OAuth error from provider
+ if (error) {
+ setStatusMessage("Authorization Failed");
+ setStatusDetails(
+ errorDescription ||
+ "The authorization was cancelled or failed. Please try again."
+ );
+ setIsError(true);
+ setIsLoading(false);
+ return;
+ }
+
+ // Validate required parameters
+ if (!code || !state) {
+ setStatusMessage("Invalid Request");
+ setStatusDetails(
+ "The authorization request was incomplete. Please try again."
+ );
+ setIsError(true);
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ // Use the generic callback endpoint
+ const url = `/api/federated/callback?code=${encodeURIComponent(
+ code
+ )}&state=${encodeURIComponent(state)}`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ let errorMessage = "Failed to complete authorization";
+ try {
+ const errorData = await response.json();
+ if (errorData.detail) {
+ // Clean up technical error messages for user-friendly display
+ if (errorData.detail.includes("validation errors")) {
+ errorMessage =
+ "Configuration error - please check your connector settings";
+ } else if (errorData.detail.includes("client_secret")) {
+ errorMessage =
+ "Authentication credentials are missing or invalid";
+ } else if (errorData.detail.includes("oauth")) {
+ errorMessage = "OAuth authorization failed";
+ } else {
+ errorMessage = "Authorization failed - please try again";
+ }
+ }
+ } catch (parseError) {
+ console.error("Error parsing response:", parseError);
+ }
+ throw new Error(errorMessage);
+ }
+
+ // Parse the response to get source information
+ const responseData = await response.json();
+ const source = responseData.source;
+ const displayName = source ? getSourceDisplayName(source) : "connector";
+
+ setStatusMessage("Success!");
+ setStatusDetails(
+ `Your ${displayName} authorization completed successfully. You can now use this connector for search.`
+ );
+ setIsSuccess(true);
+ setIsError(false);
+ setIsLoading(false);
+ } catch (error) {
+ console.error("Federated OAuth callback error:", error);
+ setStatusMessage("Something Went Wrong");
+ setStatusDetails(
+ error instanceof Error
+ ? error.message
+ : "An error occurred during the OAuth process. Please try again."
+ );
+ setIsError(true);
+ setIsLoading(false);
+ }
+ };
+
+ handleOAuthCallback();
+ }, [code, state, error, errorDescription]);
+
+ const getStatusIcon = () => {
+ if (isLoading) {
+ return (
+
+ );
+ }
+ if (isSuccess) {
+ return (
+
+ );
+ }
+ if (isError) {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ const getStatusColor = () => {
+ if (isSuccess) return "text-green-600 dark:text-green-400";
+ if (isError) return "text-red-600 dark:text-red-400";
+ return "text-gray-600 dark:text-gray-300";
+ };
+
+ return (
+
+
+
+
+ {getStatusIcon()}
+
+
+ {statusMessage}
+
+
+
+ {statusDetails}
+
+
+ {isSuccess && (
+
+
+ Redirecting to chat in 2 seconds...
+
+
+ )}
+
+
+ {isError && (
+
+ router.push("/chat")}
+ variant="navigate"
+ className="w-full"
+ >
+ Back to Chat
+
+
+ )}
+
+ {isLoading && (
+
+ This may take a few moments...
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/FederatedConnectorSelector.tsx b/web/src/components/FederatedConnectorSelector.tsx
new file mode 100644
index 0000000000..985d1985d1
--- /dev/null
+++ b/web/src/components/FederatedConnectorSelector.tsx
@@ -0,0 +1,521 @@
+import React, { useState, useRef, useEffect } from "react";
+import {
+ FederatedConnectorDetail,
+ FederatedConnectorConfig,
+ federatedSourceToRegularSource,
+ ValidSources,
+} from "@/lib/types";
+import { SourceIcon } from "@/components/SourceIcon";
+import { X, Search, Settings } from "lucide-react";
+import { Label } from "@/components/ui/label";
+import { ErrorMessage } from "formik";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+interface FederatedConnectorSelectorProps {
+ name: string;
+ label: string;
+ federatedConnectors: FederatedConnectorDetail[];
+ selectedConfigs: FederatedConnectorConfig[];
+ onChange: (selectedConfigs: FederatedConnectorConfig[]) => void;
+ disabled?: boolean;
+ placeholder?: string;
+ showError?: boolean;
+}
+
+interface EntityConfigDialogProps {
+ connectorId: number;
+ connectorName: string;
+ connectorSource: ValidSources | null;
+ currentEntities: Record
;
+ onSave: (entities: Record) => void;
+ onClose: () => void;
+ isOpen: boolean;
+}
+
+const EntityConfigDialog = ({
+ connectorId,
+ connectorName,
+ connectorSource,
+ currentEntities,
+ onSave,
+ onClose,
+ isOpen,
+}: EntityConfigDialogProps) => {
+ const [entities, setEntities] =
+ useState>(currentEntities);
+ const [entitySchema, setEntitySchema] = useState | null>(
+ null
+ );
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (isOpen && connectorId) {
+ const fetchEntitySchema = async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const response = await fetch(
+ `/api/federated/${connectorId}/entities`
+ );
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch entity schema: ${response.statusText}`
+ );
+ }
+ const data = await response.json();
+ setEntitySchema(data.entities);
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Failed to load entity schema"
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchEntitySchema();
+ }
+ }, [isOpen, connectorId]);
+
+ const handleSave = () => {
+ onSave(entities);
+ onClose();
+ };
+
+ const handleEntityChange = (key: string, value: any) => {
+ setEntities((prev) => ({
+ ...prev,
+ [key]: value,
+ }));
+ };
+
+ if (!connectorSource) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ Configure {connectorName}
+
+
+
+
+ {isLoading && (
+
+
+
+ Loading configuration...
+
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {entitySchema && !isLoading && (
+
+
+ Configure which entities to include from this connector:
+
+
+ {Object.entries(entitySchema).map(
+ ([key, field]: [string, any]) => (
+
+
+ {field.description || key}
+ {field.required && (
+ *
+ )}
+
+
+ {field.type === "list" ? (
+
+
{
+ const value = e.target.value;
+ const list = value
+ ? value
+ .split(",")
+ .map((item) => item.trim())
+ .filter(Boolean)
+ : [];
+ handleEntityChange(key, list);
+ }}
+ />
+
+ {field.description && field.description !== key
+ ? field.description
+ : `Enter ${key} separated by commas`}
+
+
+ ) : (
+
+
+ handleEntityChange(key, e.target.value)
+ }
+ />
+ {field.description && field.description !== key && (
+
+ {field.description}
+
+ )}
+
+ )}
+
+ )
+ )}
+
+ )}
+
+
+
+ Cancel
+
+
+ Save Configuration
+
+
+
+
+
+ );
+};
+
+export const FederatedConnectorSelector = ({
+ name,
+ label,
+ federatedConnectors,
+ selectedConfigs,
+ onChange,
+ disabled = false,
+ placeholder = "Search federated connectors...",
+ showError = false,
+}: FederatedConnectorSelectorProps) => {
+ const [open, setOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [configDialogState, setConfigDialogState] = useState<{
+ isOpen: boolean;
+ connectorId: number | null;
+ connectorName: string;
+ connectorSource: ValidSources | null;
+ currentEntities: Record;
+ }>({
+ isOpen: false,
+ connectorId: null,
+ connectorName: "",
+ connectorSource: null,
+ currentEntities: {},
+ });
+ const dropdownRef = useRef(null);
+ const inputRef = useRef(null);
+
+ const selectedConnectorIds = selectedConfigs.map(
+ (config) => config.federated_connector_id
+ );
+
+ const selectedConnectors = federatedConnectors.filter((connector) =>
+ selectedConnectorIds.includes(connector.id)
+ );
+
+ const unselectedConnectors = federatedConnectors.filter(
+ (connector) => !selectedConnectorIds.includes(connector.id)
+ );
+
+ const allConnectorsSelected = unselectedConnectors.length === 0;
+
+ const filteredUnselectedConnectors = unselectedConnectors.filter(
+ (connector) => {
+ const connectorName = connector.name;
+ return connectorName.toLowerCase().includes(searchQuery.toLowerCase());
+ }
+ );
+
+ useEffect(() => {
+ if (allConnectorsSelected && open) {
+ setOpen(false);
+ inputRef.current?.blur();
+ setSearchQuery("");
+ }
+ }, [allConnectorsSelected, open]);
+
+ const selectConnector = (connectorId: number) => {
+ // Add connector with empty entities configuration
+ const newConfig: FederatedConnectorConfig = {
+ federated_connector_id: connectorId,
+ entities: {},
+ };
+
+ const newSelectedConfigs = [...selectedConfigs, newConfig];
+ onChange(newSelectedConfigs);
+ setSearchQuery("");
+
+ const willAllBeSelected =
+ federatedConnectors.length === newSelectedConfigs.length;
+
+ if (!willAllBeSelected) {
+ setTimeout(() => {
+ inputRef.current?.focus();
+ }, 0);
+ }
+ };
+
+ const removeConnector = (connectorId: number) => {
+ onChange(
+ selectedConfigs.filter(
+ (config) => config.federated_connector_id !== connectorId
+ )
+ );
+ };
+
+ const openConfigDialog = (connectorId: number) => {
+ const connector = federatedConnectors.find((c) => c.id === connectorId);
+ const config = selectedConfigs.find(
+ (c) => c.federated_connector_id === connectorId
+ );
+
+ if (connector) {
+ setConfigDialogState({
+ isOpen: true,
+ connectorId,
+ connectorName: connector.name,
+ connectorSource: connector.source,
+ currentEntities: config?.entities || {},
+ });
+ }
+ };
+
+ const saveEntityConfig = (entities: Record) => {
+ const updatedConfigs = selectedConfigs.map((config) => {
+ if (config.federated_connector_id === configDialogState.connectorId) {
+ return {
+ ...config,
+ entities,
+ };
+ }
+ return config;
+ });
+ onChange(updatedConfigs);
+ };
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ inputRef.current !== event.target &&
+ !inputRef.current?.contains(event.target as Node)
+ ) {
+ setOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }, []);
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setOpen(false);
+ }
+ };
+
+ const effectivePlaceholder = allConnectorsSelected
+ ? "All federated connectors selected"
+ : placeholder;
+
+ const isInputDisabled = disabled || allConnectorsSelected;
+
+ return (
+
+ {label &&
{label} }
+
+
+ Documents from selected federated connectors will be searched in
+ real-time during queries.
+
+
+
+
+ {
+ setSearchQuery(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => {
+ if (!allConnectorsSelected) {
+ setOpen(true);
+ }
+ }}
+ onKeyDown={handleKeyDown}
+ placeholder={effectivePlaceholder}
+ className={`h-9 w-full pl-9 pr-10 py-2 bg-transparent dark:bg-transparent text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50 ${
+ allConnectorsSelected
+ ? "text-neutral-500 dark:text-neutral-400"
+ : ""
+ }`}
+ disabled={isInputDisabled}
+ />
+
+
+ {open && !allConnectorsSelected && (
+
+ {filteredUnselectedConnectors.length === 0 ? (
+
+ {searchQuery
+ ? "No matching federated connectors found"
+ : "No more federated connectors available"}
+
+ ) : (
+
+ {filteredUnselectedConnectors.map((connector) => (
+
selectConnector(connector.id)}
+ >
+
+
+
+
+
{connector.name}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {selectedConnectors.length > 0 ? (
+
+
+ {selectedConnectors.map((connector) => {
+ const config = selectedConfigs.find(
+ (c) => c.federated_connector_id === connector.id
+ );
+ const hasEntitiesConfigured =
+ config && Object.keys(config.entities).length > 0;
+
+ return (
+
+
+
+
+
+
+ {connector.name}
+
+ {hasEntitiesConfigured && (
+
+ )}
+
+
+ openConfigDialog(connector.id)}
+ aria-label="Configure entities"
+ title="Configure entities"
+ >
+
+
+ removeConnector(connector.id)}
+ aria-label="Remove connector"
+ >
+
+
+
+
+ );
+ })}
+
+
+ ) : (
+
+ No federated connectors selected. Search and select connectors above.
+
+ )}
+
+
+ setConfigDialogState((prev) => ({ ...prev, isOpen: false }))
+ }
+ isOpen={configDialogState.isOpen}
+ />
+
+ {showError && (
+
+ )}
+
+ );
+};
diff --git a/web/src/components/admin/ClientLayout.tsx b/web/src/components/admin/ClientLayout.tsx
index cf8b8aca9c..2a7b551988 100644
--- a/web/src/components/admin/ClientLayout.tsx
+++ b/web/src/components/admin/ClientLayout.tsx
@@ -43,6 +43,7 @@ import { Button } from "../ui/button";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { useIsKGExposed } from "@/app/admin/kg/utils";
+import { useFederatedOAuthStatus } from "@/lib/hooks/useFederatedOAuthStatus";
const connectors_items = () => [
{
@@ -404,9 +405,15 @@ export function ClientLayout({
const toggleUserSettings = () => {
setUserSettingsOpen(!userSettingsOpen);
};
- const { llmProviders } = useChatContext();
+ const { llmProviders, ccPairs } = useChatContext();
const { popup, setPopup } = usePopup();
+ // Fetch federated-connector info so the modal can list/refresh them
+ const {
+ connectors: federatedConnectors,
+ refetch: refetchFederatedConnectors,
+ } = useFederatedOAuthStatus();
+
if (isLoading) {
return <>>;
}
@@ -428,6 +435,9 @@ export function ClientLayout({
setPopup={setPopup}
onClose={() => setUserSettingsOpen(false)}
defaultModel={user?.preferences?.default_model!}
+ ccPairs={ccPairs}
+ federatedConnectors={federatedConnectors}
+ refetchFederatedConnectors={refetchFederatedConnectors}
/>
)}
diff --git a/web/src/components/admin/federated/FederatedConnectorForm.tsx b/web/src/components/admin/federated/FederatedConnectorForm.tsx
new file mode 100644
index 0000000000..7d7887b825
--- /dev/null
+++ b/web/src/components/admin/federated/FederatedConnectorForm.tsx
@@ -0,0 +1,586 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ ConfigurableSources,
+ CredentialSchemaResponse,
+ CredentialFieldSpec,
+ FederatedConnectorCreateRequest,
+ FederatedConnectorDetail,
+} from "@/lib/types";
+import { getSourceMetadata } from "@/lib/sources";
+import { SourceIcon } from "@/components/SourceIcon";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import { useRouter } from "next/navigation";
+import { AlertTriangle, Check, Loader2, Trash2Icon, Info } from "lucide-react";
+import { BackButton } from "@/components/BackButton";
+import Title from "@/components/ui/title";
+import { EditableStringFieldDisplay } from "@/components/EditableStringFieldDisplay";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { DropdownMenuItemWithTooltip } from "@/components/ui/dropdown-menu-with-tooltip";
+import { FiSettings } from "react-icons/fi";
+import { usePopup } from "@/components/admin/connectors/Popup";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { Badge } from "@/components/ui/badge";
+
+export interface FederatedConnectorFormProps {
+ connector: ConfigurableSources;
+ connectorId?: number; // Optional ID for editing existing connector
+ preloadedConnectorData?: FederatedConnectorDetail;
+ preloadedCredentialSchema?: CredentialSchemaResponse;
+}
+
+interface CredentialForm {
+ [key: string]: string;
+}
+
+interface FormState {
+ credentials: CredentialForm;
+ schema: Record | null;
+ schemaError: string | null;
+ connectorError: string | null;
+}
+
+async function validateCredentials(
+ source: string,
+ credentials: CredentialForm
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await fetch(
+ `/api/federated/sources/federated_${source}/credentials/validate`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(credentials),
+ }
+ );
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ return {
+ success: false,
+ message:
+ errorData.detail || `Validation failed: ${response.statusText}`,
+ };
+ }
+
+ const result = await response.json();
+ return {
+ success: result,
+ message: result ? "Credentials are valid" : "Credentials are invalid",
+ };
+ } catch (error) {
+ return { success: false, message: `Validation error: ${error}` };
+ }
+}
+
+async function createFederatedConnector(
+ source: string,
+ credentials: CredentialForm
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await fetch("/api/federated", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ source: `federated_${source}`,
+ credentials,
+ } as FederatedConnectorCreateRequest),
+ });
+
+ if (response.ok) {
+ return {
+ success: true,
+ message: "Federated connector created successfully!",
+ };
+ } else {
+ const errorData = await response.json();
+ return {
+ success: false,
+ message: errorData.detail || "Failed to create federated connector",
+ };
+ }
+ } catch (error) {
+ return { success: false, message: `Error: ${error}` };
+ }
+}
+
+async function updateFederatedConnector(
+ id: number,
+ credentials: CredentialForm
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await fetch(`/api/federated/${id}`, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ credentials,
+ }),
+ });
+
+ if (response.ok) {
+ return {
+ success: true,
+ message: "Federated connector updated successfully!",
+ };
+ } else {
+ const errorData = await response.json();
+ return {
+ success: false,
+ message: errorData.detail || "Failed to update federated connector",
+ };
+ }
+ } catch (error) {
+ return { success: false, message: `Error: ${error}` };
+ }
+}
+
+async function deleteFederatedConnector(
+ id: number
+): Promise<{ success: boolean; message: string }> {
+ try {
+ const response = await fetch(`/api/federated/${id}`, {
+ method: "DELETE",
+ });
+
+ if (response.ok) {
+ return {
+ success: true,
+ message: "Federated connector deleted successfully!",
+ };
+ } else {
+ const errorData = await response.json();
+ return {
+ success: false,
+ message: errorData.detail || "Failed to delete federated connector",
+ };
+ }
+ } catch (error) {
+ return { success: false, message: `Error: ${error}` };
+ }
+}
+
+export function FederatedConnectorForm({
+ connector,
+ connectorId,
+ preloadedConnectorData,
+ preloadedCredentialSchema,
+}: FederatedConnectorFormProps) {
+ const router = useRouter();
+ const sourceMetadata = getSourceMetadata(connector);
+ const isEditMode = connectorId !== undefined;
+ const { popup, setPopup } = usePopup();
+
+ const [formState, setFormState] = useState({
+ credentials: preloadedConnectorData?.credentials || {},
+ schema: preloadedCredentialSchema?.credentials || null,
+ schemaError: null,
+ connectorError: null,
+ });
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitMessage, setSubmitMessage] = useState(null);
+ const [submitSuccess, setSubmitSuccess] = useState(null);
+ const [isValidating, setIsValidating] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [isLoadingSchema, setIsLoadingSchema] = useState(
+ !preloadedCredentialSchema
+ );
+
+ // Fetch credential schema if not preloaded
+ useEffect(() => {
+ const fetchCredentialSchema = async () => {
+ if (!preloadedCredentialSchema) {
+ setIsLoadingSchema(true);
+ try {
+ const response = await fetch(
+ `/api/federated/sources/federated_${connector}/credentials/schema`
+ );
+
+ if (!response.ok) {
+ throw new Error(
+ `Failed to fetch credential schema: ${response.statusText}`
+ );
+ }
+
+ const schemaData: CredentialSchemaResponse = await response.json();
+ setFormState((prev) => ({
+ ...prev,
+ schema: schemaData.credentials,
+ schemaError: null,
+ }));
+ } catch (error) {
+ console.error("Error fetching credential schema:", error);
+ setFormState((prev) => ({
+ ...prev,
+ schemaError: `Failed to load credential schema: ${error}`,
+ }));
+ } finally {
+ setIsLoadingSchema(false);
+ }
+ }
+ };
+
+ fetchCredentialSchema();
+ }, [connector, preloadedCredentialSchema]);
+
+ // Show loading state at the top level if schema is loading
+ if (isLoadingSchema) {
+ return (
+
+ {popup}
+
+
+
+
+ Loading credential schema...
+
+
+ Retrieving required fields for this connector type
+
+
+
+
+ );
+ }
+
+ const handleCredentialChange = (key: string, value: string) => {
+ setFormState((prev) => ({
+ ...prev,
+ credentials: {
+ ...prev.credentials,
+ [key]: value,
+ },
+ }));
+ };
+
+ const handleValidateCredentials = async () => {
+ if (!formState.schema) return;
+
+ setIsValidating(true);
+ setSubmitMessage(null);
+ setSubmitSuccess(null);
+
+ try {
+ const result = await validateCredentials(
+ connector,
+ formState.credentials
+ );
+ setSubmitMessage(result.message);
+ setSubmitSuccess(result.success);
+ } catch (error) {
+ setSubmitMessage(`Validation error: ${error}`);
+ setSubmitSuccess(false);
+ } finally {
+ setIsValidating(false);
+ }
+ };
+
+ const handleDeleteConnector = async () => {
+ if (!connectorId) return;
+
+ const confirmed = window.confirm(
+ "Are you sure you want to delete this federated connector? This action cannot be undone."
+ );
+
+ if (!confirmed) return;
+
+ setIsDeleting(true);
+
+ try {
+ const result = await deleteFederatedConnector(connectorId);
+
+ if (result.success) {
+ setPopup({
+ message: result.message,
+ type: "success",
+ });
+ // Redirect after a short delay
+ setTimeout(() => {
+ router.push("/admin/indexing/status");
+ }, 500);
+ } else {
+ setPopup({
+ message: result.message,
+ type: "error",
+ });
+ }
+ } catch (error) {
+ setPopup({
+ message: `Error deleting connector: ${error}`,
+ type: "error",
+ });
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+ setSubmitMessage(null);
+ setSubmitSuccess(null);
+
+ try {
+ // Validate required fields
+ if (formState.schema) {
+ const missingRequired = Object.entries(formState.schema)
+ .filter(
+ ([key, field]) => field.required && !formState.credentials[key]
+ )
+ .map(([key]) => key);
+
+ if (missingRequired.length > 0) {
+ setSubmitMessage(
+ `Missing required fields: ${missingRequired.join(", ")}`
+ );
+ setSubmitSuccess(false);
+ setIsSubmitting(false);
+ return;
+ }
+ }
+
+ // Validate credentials before creating/updating
+ const validation = await validateCredentials(
+ connector,
+ formState.credentials
+ );
+ if (!validation.success) {
+ setSubmitMessage(`Credential validation failed: ${validation.message}`);
+ setSubmitSuccess(false);
+ setIsSubmitting(false);
+ return;
+ }
+
+ // Create or update the connector
+ const result =
+ isEditMode && connectorId
+ ? await updateFederatedConnector(connectorId, formState.credentials)
+ : await createFederatedConnector(connector, formState.credentials);
+
+ setSubmitMessage(result.message);
+ setSubmitSuccess(result.success);
+ setIsSubmitting(false);
+
+ if (result.success) {
+ // Redirect after a short delay
+ setTimeout(() => {
+ router.push("/admin/indexing/status");
+ }, 500);
+ }
+ } catch (error) {
+ setSubmitMessage(`Error: ${error}`);
+ setSubmitSuccess(false);
+ setIsSubmitting(false);
+ }
+ };
+
+ const renderCredentialFields = () => {
+ if (formState.schemaError) {
+ return (
+
+
+
{formState.schemaError}
+
+ );
+ }
+
+ if (formState.connectorError) {
+ return (
+
+
+
{formState.connectorError}
+
+ );
+ }
+
+ if (!formState.schema) {
+ return (
+
+ No credential schema available for this connector type.
+
+ );
+ }
+
+ return (
+ <>
+ {Object.entries(formState.schema).map(([fieldKey, fieldSpec]) => (
+
+
+ {fieldKey
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (l) => l.toUpperCase())}
+ {fieldSpec.required && (
+ *
+ )}
+
+
handleCredentialChange(fieldKey, e.target.value)}
+ className="w-full"
+ required={fieldSpec.required}
+ />
+ {fieldSpec.description && (
+
+ {fieldSpec.description}
+
+ )}
+
+ ))}
+ >
+ );
+ };
+
+ return (
+
+ {popup}
+
+
+
+
+
+
+
+
+
+
+ {isEditMode ? "Edit" : "Setup"} {sourceMetadata.displayName}
+
+
+ Federated
+
+
+
+
+
+
+
+
+ {sourceMetadata.federatedTooltip ||
+ "This is a federated connector. It will result in greater latency and lower search quality compared to regular connectors."}
+
+
+
+
+
+
+
+ {isEditMode && (
+
+
+
+
+
+ Manage
+
+
+
+
+
+ {isDeleting ? "Deleting..." : "Delete"}
+
+
+
+
+ )}
+
+
+
+ Connector Configuration
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/chat/FederatedOAuthModal.tsx b/web/src/components/chat/FederatedOAuthModal.tsx
new file mode 100644
index 0000000000..5fa0daa7b9
--- /dev/null
+++ b/web/src/components/chat/FederatedOAuthModal.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import React, { useContext } from "react";
+import { Modal } from "@/components/Modal";
+import { Button } from "@/components/ui/button";
+import { SourceIcon } from "@/components/SourceIcon";
+import { ValidSources } from "@/lib/types";
+import { SettingsContext } from "@/components/settings/SettingsProvider";
+import { getSourceMetadata } from "@/lib/sources";
+import { useRouter } from "next/navigation";
+
+export interface FederatedConnectorOAuthStatus {
+ federated_connector_id: number;
+ source: string;
+ name: string;
+ has_oauth_token: boolean;
+ oauth_token_expires_at?: string;
+ authorize_url?: string;
+}
+
+interface FederatedOAuthModalProps {
+ connectors: FederatedConnectorOAuthStatus[];
+ onSkip: () => void;
+ skipCount?: number;
+}
+
+const MAX_SKIP_COUNT = 2;
+
+export function FederatedOAuthModal({
+ connectors,
+ onSkip,
+ skipCount = 0,
+}: FederatedOAuthModalProps) {
+ const settings = useContext(SettingsContext);
+ const needsAuth = connectors.filter((c) => !c.has_oauth_token);
+ const router = useRouter();
+
+ if (needsAuth.length === 0) {
+ return null;
+ }
+
+ const handleAuthorize = (authorizeUrl: string) => {
+ // Redirect to OAuth URL in the same window
+ router.push(authorizeUrl);
+ };
+
+ const applicationName =
+ settings?.enterpriseSettings?.application_name || "Onyx";
+
+ if (skipCount >= MAX_SKIP_COUNT) {
+ return (
+ {}}
+ hideCloseButton={true}
+ width="w-full max-w-xl"
+ >
+
+
+
Heads Up!
+
+ You can always connect your apps later by going to the{" "}
+ User Settings menu (click your profile icon) and
+ selecting Connectors .
+
+
+
+
+ Got it
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Improve answer quality by letting {applicationName} search all your
+ connected data.
+
+
+
+ {needsAuth.map((connector) => {
+ const sourceMetadata = getSourceMetadata(
+ connector.source as ValidSources
+ );
+
+ return (
+
+
+
+
+ {sourceMetadata.displayName}
+
+
+
{
+ if (connector.authorize_url) {
+ handleAuthorize(connector.authorize_url);
+ }
+ }}
+ disabled={!connector.authorize_url}
+ >
+ Connect
+
+
+ );
+ })}
+
+
+ {/* Add visual separation and center modal actions */}
+
+
+
+ );
+}
diff --git a/web/src/components/filters/SourceSelector.tsx b/web/src/components/filters/SourceSelector.tsx
index 4059190769..a5d1c4ccea 100644
--- a/web/src/components/filters/SourceSelector.tsx
+++ b/web/src/components/filters/SourceSelector.tsx
@@ -15,19 +15,13 @@ import { SearchDateRangeSelector } from "@/components/dateRangeSelectors/SearchD
import { DateRangePickerValue } from "@/components/dateRangeSelectors/AdminDateRangeSelector";
import { listSourceMetadata } from "@/lib/sources";
import { SourceIcon } from "@/components/SourceIcon";
-import { TagFilter } from "@/components/search/filtering/TagFilter";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { PopoverContent } from "@radix-ui/react-popover";
import { CalendarIcon } from "lucide-react";
import { getTimeAgoString } from "@/lib/dateUtils";
-import { Separator } from "@/components/ui/separator";
import { FilterDropdown } from "@/components/search/filtering/FilterDropdown";
-const SectionTitle = ({ children }: { children: string }) => (
- {children}
-);
-
export interface SourceSelectorProps {
timeRange: DateRangePickerValue | null;
setTimeRange: React.Dispatch<
@@ -48,227 +42,6 @@ export interface SourceSelectorProps {
tagsOnLeft: boolean;
}
-export function SourceSelector({
- timeRange,
- setTimeRange,
- selectedSources,
- setSelectedSources,
- selectedDocumentSets,
- setSelectedDocumentSets,
- selectedTags,
- setSelectedTags,
- availableDocumentSets,
- existingSources,
- availableTags,
- showDocSidebar,
- toggleFilters,
- filtersUntoggled,
- tagsOnLeft,
-}: SourceSelectorProps) {
- const handleSelect = (source: SourceMetadata) => {
- setSelectedSources((prev: SourceMetadata[]) => {
- if (
- prev.map((source) => source.internalName).includes(source.internalName)
- ) {
- return prev.filter((s) => s.internalName !== source.internalName);
- } else {
- return [...prev, source];
- }
- });
- };
-
- const handleDocumentSetSelect = (documentSetName: string) => {
- setSelectedDocumentSets((prev: string[]) => {
- if (prev.includes(documentSetName)) {
- return prev.filter((s) => s !== documentSetName);
- } else {
- return [...prev, documentSetName];
- }
- });
- };
-
- let allSourcesSelected = selectedSources.length > 0;
-
- const toggleAllSources = () => {
- if (allSourcesSelected) {
- setSelectedSources([]);
- } else {
- const allSources = listSourceMetadata().filter((source) =>
- existingSources.includes(source.internalName)
- );
- setSelectedSources(allSources);
- }
- };
-
- return (
-
-
toggleFilters()} className="flex text-text-darker">
- Filters
-
-
- {!filtersUntoggled && (
- <>
-
-
-
-
-
- Time Range
- {true && (
- {
- e.stopPropagation();
- setTimeRange(null);
- }}
- className="text-sm text-text-500 hover:text-text-700"
- >
- Clear
-
- )}
-
-
- {getTimeAgoString(timeRange?.from!) || "Select a time range"}
-
-
-
-
- {
- const initialDate = daterange?.from || new Date();
- const endDate = daterange?.to || new Date();
- setTimeRange({
- from: initialDate,
- to: endDate,
- selectValue: timeRange?.selectValue || "",
- });
- }}
- className="rounded-md "
- />
-
-
-
- {availableTags.length > 0 && (
- <>
-
- Tags
-
-
- >
- )}
-
- {existingSources.length > 0 && (
-
-
-
- {listSourceMetadata()
- .filter((source) =>
- existingSources.includes(source.internalName)
- )
- .map((source) => (
-
source.internalName)
- .includes(source.internalName)
- ? "bg-accent-background-hovered"
- : "hover:bg-accent-background")
- }
- onClick={() => handleSelect(source)}
- >
-
-
- {source.displayName}
-
-
- ))}
-
-
- )}
-
- {availableDocumentSets.length > 0 && (
- <>
-
- Knowledge Sets
-
-
- {availableDocumentSets.map((documentSet) => (
-
-
handleDocumentSetSelect(documentSet.name)}
- >
-
-
-
- }
- popupContent={
-
-
Description
-
- {documentSet.description}
-
-
- }
- classNameModifications="-ml-2"
- />
-
{documentSet.name}
-
-
- ))}
-
- >
- )}
- >
- )}
-
- );
-}
-
export function SelectedBubble({
children,
onClick,
@@ -343,7 +116,10 @@ export function HorizontalFilters({
key: source.displayName,
display: (
<>
-
+
{source.displayName}
>
),
@@ -404,7 +180,10 @@ export function HorizontalFilters({
onClick={() => handleSourceSelect(source)}
>
<>
-
+
{source.displayName}
>
@@ -554,7 +333,10 @@ export function HorizontalSourceSelector({
key: source.internalName,
display: (
<>
-
+
{source.displayName}
>
),
diff --git a/web/src/components/search/filtering/FilterPopup.tsx b/web/src/components/search/filtering/FilterPopup.tsx
index 186ea14cba..514e1a0794 100644
--- a/web/src/components/search/filtering/FilterPopup.tsx
+++ b/web/src/components/search/filtering/FilterPopup.tsx
@@ -11,6 +11,7 @@ import {
FiChevronRight,
FiDatabase,
FiBook,
+ FiChevronDown,
} from "react-icons/fi";
import { FilterManager } from "@/lib/hooks";
import { DocumentSet, Tag } from "@/lib/types";
@@ -21,10 +22,20 @@ import { Button } from "@/components/ui/button";
import { SourceIcon } from "@/components/SourceIcon";
import { SelectableDropdown, TagFilter } from "./TagFilter";
import { Input } from "@/components/ui/input";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
interface FilterPopupProps {
filterManager: FilterManager;
- trigger: React.ReactNode;
+ trigger: {
+ name: string;
+ Icon: React.ComponentType<{ size?: number; className?: string }>;
+ tooltipContent?: React.ReactNode;
+ };
availableSources: SourceMetadata[];
availableDocumentSets: DocumentSet[];
availableTags: Tag[];
@@ -252,7 +263,34 @@ export function FilterPopup({
return (
- {trigger}
+
+
+
+
+ {trigger.name}
+
+
+
+
}
diff --git a/web/src/components/search/filtering/HorizontalSourceSelector.tsx b/web/src/components/search/filtering/HorizontalSourceSelector.tsx
index 7d1209ac4c..b0cefadc6d 100644
--- a/web/src/components/search/filtering/HorizontalSourceSelector.tsx
+++ b/web/src/components/search/filtering/HorizontalSourceSelector.tsx
@@ -150,7 +150,10 @@ export function HorizontalSourceSelector({
.filter((source) => existingSources.includes(source.internalName))
.map((source) => ({
icon: (
-
+
),
key: source.internalName,
display: (
diff --git a/web/src/lib/connectors/credentials.ts b/web/src/lib/connectors/credentials.ts
index b8129dd1c2..99c6713508 100644
--- a/web/src/lib/connectors/credentials.ts
+++ b/web/src/lib/connectors/credentials.ts
@@ -386,6 +386,7 @@ export const credentialTemplates: Record = {
web: null,
not_applicable: null,
ingestion_api: null,
+ federated_slack: null,
discord: { discord_bot_token: "" } as DiscordCredentialJson,
// NOTE: These are Special Cases
diff --git a/web/src/lib/hooks.ts b/web/src/lib/hooks.ts
index acc4833ddb..23343fbe85 100644
--- a/web/src/lib/hooks.ts
+++ b/web/src/lib/hooks.ts
@@ -6,7 +6,7 @@ import {
UserGroup,
ConnectorStatus,
CCPairBasicInfo,
- ValidSources,
+ FederatedConnectorDetail,
} from "@/lib/types";
import useSWR, { mutate, useSWRConfig } from "swr";
import { errorHandlingFetcher } from "./fetcher";
@@ -121,6 +121,20 @@ export const useBasicConnectorStatus = () => {
};
};
+export const useFederatedConnectors = () => {
+ const { mutate } = useSWRConfig();
+ const url = "/api/federated";
+ const swrResponse = useSWR(
+ url,
+ errorHandlingFetcher
+ );
+
+ return {
+ ...swrResponse,
+ refreshFederatedConnectors: () => mutate(url),
+ };
+};
+
export const useLabels = () => {
const { mutate } = useSWRConfig();
const { data: labels, error } = useSWR(
@@ -210,7 +224,7 @@ export interface FilterManager {
getFilterString: () => string;
buildFiltersFromQueryString: (
filterString: string,
- availableSources: ValidSources[],
+ availableSources: SourceMetadata[],
availableDocumentSets: string[],
availableTags: Tag[]
) => void;
@@ -267,7 +281,7 @@ export function useFilters(): FilterManager {
function buildFiltersFromQueryString(
filterString: string,
- availableSources: ValidSources[],
+ availableSources: SourceMetadata[],
availableDocumentSets: string[],
availableTags: Tag[]
): void {
@@ -286,12 +300,11 @@ export function useFilters(): FilterManager {
}
// Parse sources
- const availableSourcesMetadata = availableSources.map(getSourceMetadata);
let newSelectedSources: SourceMetadata[] = [];
const sourcesParam = params.get("sources");
if (sourcesParam) {
const sourceNames = sourcesParam.split(",").map(decodeURIComponent);
- newSelectedSources = availableSourcesMetadata.filter((source) =>
+ newSelectedSources = availableSources.filter((source) =>
sourceNames.includes(source.internalName)
);
}
diff --git a/web/src/lib/hooks/useFederatedOAuthStatus.ts b/web/src/lib/hooks/useFederatedOAuthStatus.ts
new file mode 100644
index 0000000000..0599d01255
--- /dev/null
+++ b/web/src/lib/hooks/useFederatedOAuthStatus.ts
@@ -0,0 +1,44 @@
+import { useState, useEffect } from "react";
+import { FederatedConnectorOAuthStatus } from "@/components/chat/FederatedOAuthModal";
+
+export function useFederatedOAuthStatus() {
+ const [connectors, setConnectors] = useState(
+ []
+ );
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const fetchOAuthStatus = async () => {
+ try {
+ setLoading(true);
+ const response = await fetch("/api/federated/oauth-status");
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch OAuth status");
+ }
+
+ const data = await response.json();
+ setConnectors(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "An error occurred");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ fetchOAuthStatus();
+ }, []);
+
+ const needsAuth = connectors.filter((c) => !c.has_oauth_token);
+ const hasUnauthenticatedConnectors = needsAuth.length > 0;
+
+ return {
+ connectors,
+ needsAuth,
+ hasUnauthenticatedConnectors,
+ loading,
+ error,
+ refetch: fetchOAuthStatus,
+ };
+}
diff --git a/web/src/lib/oauth_utils.ts b/web/src/lib/oauth_utils.ts
index db3342efed..2236fa45f0 100644
--- a/web/src/lib/oauth_utils.ts
+++ b/web/src/lib/oauth_utils.ts
@@ -62,6 +62,56 @@ export async function handleOAuthAuthorizationResponse(
return;
}
+// Handler for federated connector OAuth callbacks
+export async function handleFederatedOAuthCallback(
+ federatedConnectorId: string,
+ code: string,
+ state: string
+): Promise {
+ // Use the generic callback endpoint - the connector ID will be extracted from the state parameter
+ const url = `/api/federated/callback?code=${encodeURIComponent(
+ code
+ )}&state=${encodeURIComponent(state)}`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ });
+
+ if (!response.ok) {
+ let errorDetails = `Failed to handle federated OAuth callback: ${response.status}`;
+
+ try {
+ const responseBody = await response.text();
+ errorDetails += `\nResponse Body: ${responseBody}`;
+ } catch (err) {
+ if (err instanceof Error) {
+ errorDetails += `\nUnable to read response body: ${err.message}`;
+ } else {
+ errorDetails += `\nUnable to read response body: Unknown error type`;
+ }
+ }
+
+ throw new Error(errorDetails);
+ }
+
+ // Parse the JSON response and extract the data field
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.message || "OAuth callback failed");
+ }
+
+ return {
+ success: true,
+ message: result.message || "OAuth authorization successful",
+ redirect_on_success: `/admin/federated/${federatedConnectorId}`,
+ finalize_url: null,
+ };
+}
+
// server side handler to process the oauth redirect callback
// https://api.slack.com/authentication/oauth-v2#exchanging
export async function handleOAuthSlackAuthorizationResponse(
diff --git a/web/src/lib/search/interfaces.ts b/web/src/lib/search/interfaces.ts
index faf2e2579f..1fde64f8d9 100644
--- a/web/src/lib/search/interfaces.ts
+++ b/web/src/lib/search/interfaces.ts
@@ -180,6 +180,10 @@ export interface SourceMetadata {
internalName: ValidSources;
adminUrl: string;
oauthSupported?: boolean;
+ federated?: boolean;
+ federatedTooltip?: string;
+ // For federated connectors, this stores the base source type for the icon
+ baseSourceType?: ValidSources;
}
export interface SearchDefaultOverrides {
diff --git a/web/src/lib/sources.ts b/web/src/lib/sources.ts
index 32249535bc..ce0bd90da6 100644
--- a/web/src/lib/sources.ts
+++ b/web/src/lib/sources.ts
@@ -54,10 +54,29 @@ interface PartialSourceMetadata {
displayName: string;
category: SourceCategory;
docs?: string;
+ oauthSupported?: boolean;
+ federated?: boolean;
+ federatedTooltip?: string;
+ // federated connectors store the base source type if it's a source
+ // that has both indexed connectors and federated connectors
+ baseSourceType?: ValidSources;
}
type SourceMap = {
- [K in ValidSources]: PartialSourceMetadata;
+ [K in ValidSources | "federated_slack"]: PartialSourceMetadata;
+};
+
+const slackMetadata = {
+ icon: ColorSlackIcon,
+ displayName: "Slack",
+ category: SourceCategory.Messaging,
+ docs: "https://docs.onyx.app/connectors/slack",
+ oauthSupported: true,
+ federated: true,
+ federatedTooltip:
+ "⚠️ WARNING: Due to Slack's rate limit and ToS changes, Slack is now federated. " +
+ "This will result in significantly greater latency and lower search quality.",
+ baseSourceType: "slack",
};
export const SOURCE_METADATA_MAP: SourceMap = {
@@ -73,13 +92,8 @@ export const SOURCE_METADATA_MAP: SourceMap = {
category: SourceCategory.Storage,
docs: "https://docs.onyx.app/connectors/file",
},
- slack: {
- icon: ColorSlackIcon,
- displayName: "Slack",
- category: SourceCategory.Messaging,
- docs: "https://docs.onyx.app/connectors/slack",
- oauthSupported: true,
- },
+ slack: slackMetadata,
+ federated_slack: slackMetadata,
discord: {
icon: ColorDiscordIcon,
displayName: "Discord",
@@ -352,7 +366,7 @@ function fillSourceMetadata(
internalName: ValidSources
): SourceMetadata {
return {
- internalName: internalName,
+ internalName: partialMetadata.baseSourceType || internalName,
...partialMetadata,
adminUrl: `/admin/connectors/${internalName}`,
};
@@ -375,7 +389,9 @@ export function listSourceMetadata(): SourceMetadata[] {
([source, _]) =>
source !== "not_applicable" &&
source !== "ingestion_api" &&
- source !== "mock_connector"
+ source !== "mock_connector" &&
+ // use the "regular" slack connector when listing
+ source !== "federated_slack"
)
.map(([source, metadata]) => {
return fillSourceMetadata(metadata, source as ValidSources);
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 3d9b2b4017..4f94ab2122 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -175,6 +175,20 @@ export interface ConnectorIndexingStatus<
docs_indexed: number;
}
+export interface FederatedConnectorDetail {
+ id: number;
+ source: ValidSources.FederatedSlack;
+ name: string;
+ credentials: Record;
+ oauth_token_exists: boolean;
+ oauth_token_expires_at: string | null;
+ document_sets: Array<{
+ id: number;
+ name: string;
+ entities: Record;
+ }>;
+}
+
export interface OAuthPrepareAuthorizationResponse {
url: string;
}
@@ -243,6 +257,18 @@ export interface CCPairDescriptor {
access_type: AccessType;
}
+export interface FederatedConnectorConfig {
+ federated_connector_id: number;
+ entities: Record;
+}
+
+export interface FederatedConnectorDescriptor {
+ id: number;
+ name: string;
+ source: string;
+ entities: Record;
+}
+
export interface DocumentSet {
id: number;
name: string;
@@ -252,6 +278,7 @@ export interface DocumentSet {
is_public: boolean;
users: string[];
groups: number[];
+ federated_connectors: FederatedConnectorDescriptor[];
}
export interface Tag {
@@ -392,8 +419,20 @@ export enum ValidSources {
Airtable = "airtable",
Gitbook = "gitbook",
Highspot = "highspot",
+
+ // Federated Connectors
+ FederatedSlack = "federated_slack",
}
+export const federatedSourceToRegularSource = (
+ maybeFederatedSource: ValidSources
+): ValidSources => {
+ if (maybeFederatedSource === ValidSources.FederatedSlack) {
+ return ValidSources.Slack;
+ }
+ return maybeFederatedSource;
+};
+
export const validAutoSyncSources = [
ValidSources.Confluence,
ValidSources.GoogleDrive,
@@ -407,7 +446,9 @@ export type ValidAutoSyncSource = (typeof validAutoSyncSources)[number];
export type ConfigurableSources = Exclude<
ValidSources,
- ValidSources.NotApplicable | ValidSources.IngestionApi
+ | ValidSources.NotApplicable
+ | ValidSources.IngestionApi
+ | ValidSources.FederatedSlack // is part of ValiedSources.Slack
>;
export const oauthSupportedSources: ConfigurableSources[] = [
@@ -418,3 +459,27 @@ export const oauthSupportedSources: ConfigurableSources[] = [
];
export type OAuthSupportedSource = (typeof oauthSupportedSources)[number];
+
+// Federated Connector Types
+export interface CredentialFieldSpec {
+ type: string;
+ description: string;
+ required: boolean;
+ default?: any;
+ example?: any;
+ secret: boolean;
+}
+
+export interface CredentialSchemaResponse {
+ credentials: Record;
+}
+
+export interface FederatedConnectorCreateRequest {
+ source: string;
+ credentials: Record;
+}
+
+export interface FederatedConnectorCreateResponse {
+ id: number;
+ source: string;
+}