Merge pull request #234 from CREDO23/feature/add-confluence-Connector

[Feature] Add confluence connector
This commit is contained in:
Rohan Verma 2025-07-28 20:25:57 +05:30 committed by GitHub
commit fbb1263aa3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1862 additions and 251 deletions

View file

@ -208,6 +208,33 @@ export default function EditConnectorPage() {
</div>
)}
{/* == Confluence == */}
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_BASE_URL"
fieldLabel="Confluence Base URL"
fieldDescription="Update your Confluence instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_EMAIL"
fieldLabel="Confluence Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_API_TOKEN"
fieldLabel="Confluence API Token"
fieldDescription="Update your Confluence API Token if needed."
placeholder="Your Confluence API Token"
/>
</div>
)}
{/* == Linkup == */}
{connector.connector_type === "LINKUP_API" && (
<EditSimpleTokenForm

View file

@ -0,0 +1,324 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
// Define the form schema with Zod
const confluenceConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message:
"Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("confluence");
},
{
message: "Please enter a valid Confluence instance URL",
},
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Confluence API Token is required and must be valid.",
}),
});
// Define the type for the form values
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
export default function ConfluenceConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
// Initialize the form
const form = useForm<ConfluenceConnectorFormValues>({
resolver: zodResolver(confluenceConnectorFormSchema),
defaultValues: {
name: "Confluence Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Handle form submission
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "CONFLUENCE_CONNECTOR",
config: {
CONFLUENCE_BASE_URL: values.base_url,
CONFLUENCE_EMAIL: values.email,
CONFLUENCE_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Confluence connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(
error instanceof Error ? error.message : "Failed to create connector",
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() =>
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card>
<CardHeader>
<CardTitle>Connect to Confluence</CardTitle>
<CardDescription>
Connect your Confluence instance to index pages and comments from your spaces.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
You'll need to create an API token from your{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Confluence Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Confluence Instance URL</FormLabel>
<FormControl>
<Input
placeholder="https://yourcompany.atlassian.net"
{...field}
/>
</FormControl>
<FormDescription>
Your Confluence instance URL. For Atlassian Cloud, this is
typically https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
type="email"
placeholder="your.email@company.com"
{...field}
/>
</FormControl>
<FormDescription>
Your Atlassian account email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Confluence API Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Confluence API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Confluence
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card>
<CardHeader>
<CardTitle>Confluence Integration Guide</CardTitle>
<CardDescription>
Learn how to set up and use the Confluence connector.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>All pages from accessible spaces</li>
<li>Page content and metadata</li>
<li>Comments on pages (both footer and inline comments)</li>
<li>Page titles and descriptions</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>Go to your Atlassian Account Settings</li>
<li>Navigate to Security API tokens</li>
<li>Create a new API token with appropriate permissions</li>
<li>Copy the token and paste it in the form above</li>
<li>Ensure your account has read access to the spaces you want to index</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Read access to Confluence spaces</li>
<li>View pages and comments</li>
<li>Access to space metadata</li>
</ul>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
The connector will only index content that your account has permission to view.
Make sure your API token has the necessary permissions for the spaces you want to index.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -13,6 +13,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
IconBook,
IconBrandDiscord,
IconBrandGithub,
IconBrandNotion,
@ -141,6 +142,14 @@ const connectorCategories: ConnectorCategory[] = [
icon: <IconBrandGithub className="h-6 w-6" />,
status: "available",
},
{
id: "confluence-connector",
title: "Confluence",
description:
"Connect to Confluence to search pages, comments and documentation.",
icon: <IconBook className="h-6 w-6" />,
status: "available",
},
],
},
{

View file

@ -57,6 +57,7 @@ import {
IconBrandDiscord,
IconBrandGithub,
IconBrandNotion,
IconBook,
IconBrandSlack,
IconBrandYoutube,
IconLayoutKanban,
@ -180,6 +181,7 @@ const documentTypeIcons = {
LINEAR_CONNECTOR: IconLayoutKanban,
JIRA_CONNECTOR: IconTicket,
DISCORD_CONNECTOR: IconBrandDiscord,
CONFLUENCE_CONNECTOR: IconBook,
} as const;
const columns: ColumnDef<Document>[] = [

View file

@ -32,5 +32,11 @@ export const editConnectorSchema = z.object({
LINEAR_API_KEY: z.string().optional(),
LINKUP_API_KEY: z.string().optional(),
DISCORD_BOT_TOKEN: z.string().optional(),
CONFLUENCE_BASE_URL: z.string().optional(),
CONFLUENCE_EMAIL: z.string().optional(),
CONFLUENCE_API_TOKEN: z.string().optional(),
JIRA_BASE_URL: z.string().optional(),
JIRA_EMAIL: z.string().optional(),
JIRA_API_TOKEN: z.string().optional(),
});
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;

View file

@ -1,259 +1,494 @@
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { useSearchSourceConnectors, SearchSourceConnector } from '@/hooks/useSearchSourceConnectors';
import {
GithubRepo,
EditMode,
githubPatSchema,
editConnectorSchema,
GithubPatFormValues,
EditConnectorFormValues
} from '@/components/editConnector/types';
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import {
useSearchSourceConnectors,
SearchSourceConnector,
} from "@/hooks/useSearchSourceConnectors";
import {
GithubRepo,
EditMode,
githubPatSchema,
editConnectorSchema,
GithubPatFormValues,
EditConnectorFormValues,
} from "@/components/editConnector/types";
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
const router = useRouter();
const { connectors, updateConnector, isLoading: connectorsLoading } = useSearchSourceConnectors();
export function useConnectorEditPage(
connectorId: number,
searchSpaceId: string,
) {
const router = useRouter();
const {
connectors,
updateConnector,
isLoading: connectorsLoading,
} = useSearchSourceConnectors();
// State managed by the hook
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [originalConfig, setOriginalConfig] = useState<Record<string, any> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [currentSelectedRepos, setCurrentSelectedRepos] = useState<string[]>([]);
const [originalPat, setOriginalPat] = useState<string>("");
const [editMode, setEditMode] = useState<EditMode>('viewing');
const [fetchedRepos, setFetchedRepos] = useState<GithubRepo[] | null>(null);
const [newSelectedRepos, setNewSelectedRepos] = useState<string[]>([]);
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
// State managed by the hook
const [connector, setConnector] = useState<SearchSourceConnector | null>(
null,
);
const [originalConfig, setOriginalConfig] = useState<Record<
string,
any
> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [currentSelectedRepos, setCurrentSelectedRepos] = useState<string[]>(
[],
);
const [originalPat, setOriginalPat] = useState<string>("");
const [editMode, setEditMode] = useState<EditMode>("viewing");
const [fetchedRepos, setFetchedRepos] = useState<GithubRepo[] | null>(null);
const [newSelectedRepos, setNewSelectedRepos] = useState<string[]>([]);
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
// Forms managed by the hook
const patForm = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatSchema),
defaultValues: { github_pat: "" },
});
const editForm = useForm<EditConnectorFormValues>({
resolver: zodResolver(editConnectorSchema),
defaultValues: {
name: "",
SLACK_BOT_TOKEN: "",
NOTION_INTEGRATION_TOKEN: "",
SERPER_API_KEY: "",
TAVILY_API_KEY: "",
LINEAR_API_KEY: "",
DISCORD_BOT_TOKEN: "",
},
});
// Forms managed by the hook
const patForm = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatSchema),
defaultValues: { github_pat: "" },
});
const editForm = useForm<EditConnectorFormValues>({
resolver: zodResolver(editConnectorSchema),
defaultValues: {
name: "",
SLACK_BOT_TOKEN: "",
NOTION_INTEGRATION_TOKEN: "",
SERPER_API_KEY: "",
TAVILY_API_KEY: "",
LINEAR_API_KEY: "",
DISCORD_BOT_TOKEN: "",
CONFLUENCE_BASE_URL: "",
CONFLUENCE_EMAIL: "",
CONFLUENCE_API_TOKEN: "",
JIRA_BASE_URL: "",
JIRA_EMAIL: "",
JIRA_API_TOKEN: "",
},
});
// Effect to load initial data
useEffect(() => {
if (!connectorsLoading && connectors.length > 0 && !connector) {
const currentConnector = connectors.find(c => c.id === connectorId);
if (currentConnector) {
setConnector(currentConnector);
const config = currentConnector.config || {};
setOriginalConfig(config);
editForm.reset({
name: currentConnector.name,
SLACK_BOT_TOKEN: config.SLACK_BOT_TOKEN || "",
NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "",
SERPER_API_KEY: config.SERPER_API_KEY || "",
TAVILY_API_KEY: config.TAVILY_API_KEY || "",
LINEAR_API_KEY: config.LINEAR_API_KEY || "",
LINKUP_API_KEY: config.LINKUP_API_KEY || "",
DISCORD_BOT_TOKEN: config.DISCORD_BOT_TOKEN || "",
});
if (currentConnector.connector_type === 'GITHUB_CONNECTOR') {
const savedRepos = config.repo_full_names || [];
const savedPat = config.GITHUB_PAT || "";
setCurrentSelectedRepos(savedRepos);
setNewSelectedRepos(savedRepos);
setOriginalPat(savedPat);
patForm.reset({ github_pat: savedPat });
setEditMode('viewing');
}
} else {
toast.error("Connector not found.");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
// Effect to load initial data
useEffect(() => {
if (!connectorsLoading && connectors.length > 0 && !connector) {
const currentConnector = connectors.find((c) => c.id === connectorId);
if (currentConnector) {
setConnector(currentConnector);
const config = currentConnector.config || {};
setOriginalConfig(config);
editForm.reset({
name: currentConnector.name,
SLACK_BOT_TOKEN: config.SLACK_BOT_TOKEN || "",
NOTION_INTEGRATION_TOKEN: config.NOTION_INTEGRATION_TOKEN || "",
SERPER_API_KEY: config.SERPER_API_KEY || "",
TAVILY_API_KEY: config.TAVILY_API_KEY || "",
LINEAR_API_KEY: config.LINEAR_API_KEY || "",
LINKUP_API_KEY: config.LINKUP_API_KEY || "",
DISCORD_BOT_TOKEN: config.DISCORD_BOT_TOKEN || "",
CONFLUENCE_BASE_URL: config.CONFLUENCE_BASE_URL || "",
CONFLUENCE_EMAIL: config.CONFLUENCE_EMAIL || "",
CONFLUENCE_API_TOKEN: config.CONFLUENCE_API_TOKEN || "",
JIRA_BASE_URL: config.JIRA_BASE_URL || "",
JIRA_EMAIL: config.JIRA_EMAIL || "",
JIRA_API_TOKEN: config.JIRA_API_TOKEN || "",
});
if (currentConnector.connector_type === "GITHUB_CONNECTOR") {
const savedRepos = config.repo_full_names || [];
const savedPat = config.GITHUB_PAT || "";
setCurrentSelectedRepos(savedRepos);
setNewSelectedRepos(savedRepos);
setOriginalPat(savedPat);
patForm.reset({ github_pat: savedPat });
setEditMode("viewing");
}
}, [connectorId, connectors, connectorsLoading, router, searchSpaceId, connector, editForm, patForm]);
} else {
toast.error("Connector not found.");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}
}, [
connectorId,
connectors,
connectorsLoading,
router,
searchSpaceId,
connector,
editForm,
patForm,
]);
// Handlers managed by the hook
const handleFetchRepositories = useCallback(async (values: GithubPatFormValues) => {
setIsFetchingRepos(true);
setFetchedRepos(null);
try {
const token = localStorage.getItem('surfsense_bearer_token');
if (!token) throw new Error('No auth token');
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ github_pat: values.github_pat }) }
// Handlers managed by the hook
const handleFetchRepositories = useCallback(
async (values: GithubPatFormValues) => {
setIsFetchingRepos(true);
setFetchedRepos(null);
try {
const token = localStorage.getItem("surfsense_bearer_token");
if (!token) throw new Error("No auth token");
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ github_pat: values.github_pat }),
},
);
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || "Fetch failed");
}
const data: GithubRepo[] = await response.json();
setFetchedRepos(data);
setNewSelectedRepos(currentSelectedRepos);
toast.success(`Found ${data.length} repos.`);
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to fetch repositories.",
);
} finally {
setIsFetchingRepos(false);
}
},
[currentSelectedRepos],
); // Added dependency
const handleRepoSelectionChange = useCallback(
(repoFullName: string, checked: boolean) => {
setNewSelectedRepos((prev) =>
checked
? [...prev, repoFullName]
: prev.filter((name) => name !== repoFullName),
);
},
[],
);
const handleSaveChanges = useCallback(
async (formData: EditConnectorFormValues) => {
if (!connector || !originalConfig) return;
setIsSaving(true);
const updatePayload: Partial<SearchSourceConnector> = {};
let configChanged = false;
let newConfig: Record<string, any> | null = null;
if (formData.name !== connector.name) {
updatePayload.name = formData.name;
}
switch (connector.connector_type) {
case "GITHUB_CONNECTOR": {
const currentPatInForm = patForm.getValues("github_pat");
const patChanged = currentPatInForm !== originalPat;
const initialRepoSet = new Set(currentSelectedRepos);
const newRepoSet = new Set(newSelectedRepos);
const reposChanged =
initialRepoSet.size !== newRepoSet.size ||
![...initialRepoSet].every((repo) => newRepoSet.has(repo));
if (
patChanged ||
(editMode === "editing_repos" &&
reposChanged &&
fetchedRepos !== null)
) {
if (
!currentPatInForm ||
!(
currentPatInForm.startsWith("ghp_") ||
currentPatInForm.startsWith("github_pat_")
)
) {
toast.error("Invalid GitHub PAT format. Cannot save.");
setIsSaving(false);
return;
}
newConfig = {
GITHUB_PAT: currentPatInForm,
repo_full_names: newSelectedRepos,
};
if (reposChanged && newSelectedRepos.length === 0) {
toast.warning("Warning: No repositories selected.");
}
}
break;
}
case "SLACK_CONNECTOR":
if (formData.SLACK_BOT_TOKEN !== originalConfig.SLACK_BOT_TOKEN) {
if (!formData.SLACK_BOT_TOKEN) {
toast.error("Slack Token empty.");
setIsSaving(false);
return;
}
newConfig = { SLACK_BOT_TOKEN: formData.SLACK_BOT_TOKEN };
}
break;
case "NOTION_CONNECTOR":
if (
formData.NOTION_INTEGRATION_TOKEN !==
originalConfig.NOTION_INTEGRATION_TOKEN
) {
if (!formData.NOTION_INTEGRATION_TOKEN) {
toast.error("Notion Token empty.");
setIsSaving(false);
return;
}
newConfig = {
NOTION_INTEGRATION_TOKEN: formData.NOTION_INTEGRATION_TOKEN,
};
}
break;
case "SERPER_API":
if (formData.SERPER_API_KEY !== originalConfig.SERPER_API_KEY) {
if (!formData.SERPER_API_KEY) {
toast.error("Serper Key empty.");
setIsSaving(false);
return;
}
newConfig = { SERPER_API_KEY: formData.SERPER_API_KEY };
}
break;
case "TAVILY_API":
if (formData.TAVILY_API_KEY !== originalConfig.TAVILY_API_KEY) {
if (!formData.TAVILY_API_KEY) {
toast.error("Tavily Key empty.");
setIsSaving(false);
return;
}
newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY };
}
break;
case "LINEAR_CONNECTOR":
if (formData.LINEAR_API_KEY !== originalConfig.LINEAR_API_KEY) {
if (!formData.LINEAR_API_KEY) {
toast.error("Linear API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { LINEAR_API_KEY: formData.LINEAR_API_KEY };
}
break;
case "LINKUP_API":
if (formData.LINKUP_API_KEY !== originalConfig.LINKUP_API_KEY) {
if (!formData.LINKUP_API_KEY) {
toast.error("Linkup API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { LINKUP_API_KEY: formData.LINKUP_API_KEY };
}
break;
case "DISCORD_CONNECTOR":
if (formData.DISCORD_BOT_TOKEN !== originalConfig.DISCORD_BOT_TOKEN) {
if (!formData.DISCORD_BOT_TOKEN) {
toast.error("Discord Bot Token cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { DISCORD_BOT_TOKEN: formData.DISCORD_BOT_TOKEN };
}
break;
case "CONFLUENCE_CONNECTOR":
if (
formData.CONFLUENCE_BASE_URL !==
originalConfig.CONFLUENCE_BASE_URL ||
formData.CONFLUENCE_EMAIL !== originalConfig.CONFLUENCE_EMAIL ||
formData.CONFLUENCE_API_TOKEN !==
originalConfig.CONFLUENCE_API_TOKEN
) {
if (
!formData.CONFLUENCE_BASE_URL ||
!formData.CONFLUENCE_EMAIL ||
!formData.CONFLUENCE_API_TOKEN
) {
toast.error("All Confluence fields are required.");
setIsSaving(false);
return;
}
newConfig = {
CONFLUENCE_BASE_URL: formData.CONFLUENCE_BASE_URL,
CONFLUENCE_EMAIL: formData.CONFLUENCE_EMAIL,
CONFLUENCE_API_TOKEN: formData.CONFLUENCE_API_TOKEN,
};
}
break;
case "JIRA_CONNECTOR":
if (
formData.JIRA_BASE_URL !== originalConfig.JIRA_BASE_URL ||
formData.JIRA_EMAIL !== originalConfig.JIRA_EMAIL ||
formData.JIRA_API_TOKEN !== originalConfig.JIRA_API_TOKEN
) {
if (
!formData.JIRA_BASE_URL ||
!formData.JIRA_EMAIL ||
!formData.JIRA_API_TOKEN
) {
toast.error("All Jira fields are required.");
setIsSaving(false);
return;
}
newConfig = {
JIRA_BASE_URL: formData.JIRA_BASE_URL,
JIRA_EMAIL: formData.JIRA_EMAIL,
JIRA_API_TOKEN: formData.JIRA_API_TOKEN,
};
}
break;
}
if (newConfig !== null) {
updatePayload.config = newConfig;
configChanged = true;
}
if (Object.keys(updatePayload).length === 0) {
toast.info("No changes detected.");
setIsSaving(false);
if (connector.connector_type === "GITHUB_CONNECTOR") {
setEditMode("viewing");
patForm.reset({ github_pat: originalPat });
}
return;
}
try {
await updateConnector(connectorId, updatePayload);
toast.success("Connector updated!");
const newlySavedConfig = updatePayload.config || originalConfig;
setOriginalConfig(newlySavedConfig);
if (updatePayload.name) {
setConnector((prev) =>
prev
? { ...prev, name: updatePayload.name!, config: newlySavedConfig }
: null,
);
}
if (configChanged) {
if (connector.connector_type === "GITHUB_CONNECTOR") {
const savedGitHubConfig = newlySavedConfig as {
GITHUB_PAT?: string;
repo_full_names?: string[];
};
setCurrentSelectedRepos(savedGitHubConfig.repo_full_names || []);
setOriginalPat(savedGitHubConfig.GITHUB_PAT || "");
setNewSelectedRepos(savedGitHubConfig.repo_full_names || []);
patForm.reset({ github_pat: savedGitHubConfig.GITHUB_PAT || "" });
} else if (connector.connector_type === "SLACK_CONNECTOR") {
editForm.setValue(
"SLACK_BOT_TOKEN",
newlySavedConfig.SLACK_BOT_TOKEN || "",
);
if (!response.ok) { const err = await response.json(); throw new Error(err.detail || 'Fetch failed'); }
const data: GithubRepo[] = await response.json();
setFetchedRepos(data);
setNewSelectedRepos(currentSelectedRepos);
toast.success(`Found ${data.length} repos.`);
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
toast.error(error instanceof Error ? error.message : "Failed to fetch repositories.");
} finally { setIsFetchingRepos(false); }
}, [currentSelectedRepos]); // Added dependency
const handleRepoSelectionChange = useCallback((repoFullName: string, checked: boolean) => {
setNewSelectedRepos(prev => checked ? [...prev, repoFullName] : prev.filter(name => name !== repoFullName));
}, []);
const handleSaveChanges = useCallback(async (formData: EditConnectorFormValues) => {
if (!connector || !originalConfig) return;
setIsSaving(true);
const updatePayload: Partial<SearchSourceConnector> = {};
let configChanged = false;
let newConfig: Record<string, any> | null = null;
if (formData.name !== connector.name) {
updatePayload.name = formData.name;
} else if (connector.connector_type === "NOTION_CONNECTOR") {
editForm.setValue(
"NOTION_INTEGRATION_TOKEN",
newlySavedConfig.NOTION_INTEGRATION_TOKEN || "",
);
} else if (connector.connector_type === "SERPER_API") {
editForm.setValue(
"SERPER_API_KEY",
newlySavedConfig.SERPER_API_KEY || "",
);
} else if (connector.connector_type === "TAVILY_API") {
editForm.setValue(
"TAVILY_API_KEY",
newlySavedConfig.TAVILY_API_KEY || "",
);
} else if (connector.connector_type === "LINEAR_CONNECTOR") {
editForm.setValue(
"LINEAR_API_KEY",
newlySavedConfig.LINEAR_API_KEY || "",
);
} else if (connector.connector_type === "LINKUP_API") {
editForm.setValue(
"LINKUP_API_KEY",
newlySavedConfig.LINKUP_API_KEY || "",
);
} else if (connector.connector_type === "DISCORD_CONNECTOR") {
editForm.setValue(
"DISCORD_BOT_TOKEN",
newlySavedConfig.DISCORD_BOT_TOKEN || "",
);
} else if (connector.connector_type === "CONFLUENCE_CONNECTOR") {
editForm.setValue(
"CONFLUENCE_BASE_URL",
newlySavedConfig.CONFLUENCE_BASE_URL || "",
);
editForm.setValue(
"CONFLUENCE_EMAIL",
newlySavedConfig.CONFLUENCE_EMAIL || "",
);
editForm.setValue(
"CONFLUENCE_API_TOKEN",
newlySavedConfig.CONFLUENCE_API_TOKEN || "",
);
} else if (connector.connector_type === "JIRA_CONNECTOR") {
editForm.setValue(
"JIRA_BASE_URL",
newlySavedConfig.JIRA_BASE_URL || "",
);
editForm.setValue("JIRA_EMAIL", newlySavedConfig.JIRA_EMAIL || "");
editForm.setValue(
"JIRA_API_TOKEN",
newlySavedConfig.JIRA_API_TOKEN || "",
);
}
}
switch (connector.connector_type) {
case 'GITHUB_CONNECTOR':
const currentPatInForm = patForm.getValues('github_pat');
const patChanged = currentPatInForm !== originalPat;
const initialRepoSet = new Set(currentSelectedRepos);
const newRepoSet = new Set(newSelectedRepos);
const reposChanged = initialRepoSet.size !== newRepoSet.size || ![...initialRepoSet].every(repo => newRepoSet.has(repo));
if (patChanged || (editMode === 'editing_repos' && reposChanged && fetchedRepos !== null)) {
if (!currentPatInForm || !(currentPatInForm.startsWith('ghp_') || currentPatInForm.startsWith('github_pat_'))) {
toast.error("Invalid GitHub PAT format. Cannot save."); setIsSaving(false); return;
}
newConfig = { GITHUB_PAT: currentPatInForm, repo_full_names: newSelectedRepos };
if (reposChanged && newSelectedRepos.length === 0) { toast.warning("Warning: No repositories selected."); }
}
break;
case 'SLACK_CONNECTOR':
if (formData.SLACK_BOT_TOKEN !== originalConfig.SLACK_BOT_TOKEN) {
if (!formData.SLACK_BOT_TOKEN) { toast.error("Slack Token empty."); setIsSaving(false); return; }
newConfig = { SLACK_BOT_TOKEN: formData.SLACK_BOT_TOKEN };
}
break;
case 'NOTION_CONNECTOR':
if (formData.NOTION_INTEGRATION_TOKEN !== originalConfig.NOTION_INTEGRATION_TOKEN) {
if (!formData.NOTION_INTEGRATION_TOKEN) { toast.error("Notion Token empty."); setIsSaving(false); return; }
newConfig = { NOTION_INTEGRATION_TOKEN: formData.NOTION_INTEGRATION_TOKEN };
}
break;
case 'SERPER_API':
if (formData.SERPER_API_KEY !== originalConfig.SERPER_API_KEY) {
if (!formData.SERPER_API_KEY) { toast.error("Serper Key empty."); setIsSaving(false); return; }
newConfig = { SERPER_API_KEY: formData.SERPER_API_KEY };
}
break;
case 'TAVILY_API':
if (formData.TAVILY_API_KEY !== originalConfig.TAVILY_API_KEY) {
if (!formData.TAVILY_API_KEY) { toast.error("Tavily Key empty."); setIsSaving(false); return; }
newConfig = { TAVILY_API_KEY: formData.TAVILY_API_KEY };
}
break;
case 'LINEAR_CONNECTOR':
if (formData.LINEAR_API_KEY !== originalConfig.LINEAR_API_KEY) {
if (!formData.LINEAR_API_KEY) {
toast.error("Linear API Key cannot be empty.");
setIsSaving(false);
return;
}
newConfig = { LINEAR_API_KEY: formData.LINEAR_API_KEY };
}
break;
case 'LINKUP_API':
if (formData.LINKUP_API_KEY !== originalConfig.LINKUP_API_KEY) {
if (!formData.LINKUP_API_KEY) { toast.error("Linkup API Key cannot be empty."); setIsSaving(false); return; }
newConfig = { LINKUP_API_KEY: formData.LINKUP_API_KEY };
}
break;
case 'DISCORD_CONNECTOR':
if (formData.DISCORD_BOT_TOKEN !== originalConfig.DISCORD_BOT_TOKEN) {
if (!formData.DISCORD_BOT_TOKEN) { toast.error("Discord Bot Token cannot be empty."); setIsSaving(false); return; }
newConfig = { DISCORD_BOT_TOKEN: formData.DISCORD_BOT_TOKEN };
}
break;
if (connector.connector_type === "GITHUB_CONNECTOR") {
setEditMode("viewing");
setFetchedRepos(null);
}
// Resetting simple form values is handled by useEffect if connector state updates
} catch (error) {
console.error("Error updating connector:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to update connector.",
);
} finally {
setIsSaving(false);
}
},
[
connector,
originalConfig,
updateConnector,
connectorId,
patForm,
originalPat,
currentSelectedRepos,
newSelectedRepos,
editMode,
fetchedRepos,
editForm,
],
); // Added editForm to dependencies
if (newConfig !== null) {
updatePayload.config = newConfig;
configChanged = true;
}
if (Object.keys(updatePayload).length === 0) {
toast.info("No changes detected.");
setIsSaving(false);
if (connector.connector_type === 'GITHUB_CONNECTOR') { setEditMode('viewing'); patForm.reset({ github_pat: originalPat }); }
return;
}
try {
await updateConnector(connectorId, updatePayload);
toast.success("Connector updated!");
const newlySavedConfig = updatePayload.config || originalConfig;
setOriginalConfig(newlySavedConfig);
if (updatePayload.name) {
setConnector(prev => prev ? { ...prev, name: updatePayload.name!, config: newlySavedConfig } : null);
}
if (configChanged) {
if (connector.connector_type === 'GITHUB_CONNECTOR') {
const savedGitHubConfig = newlySavedConfig as { GITHUB_PAT?: string; repo_full_names?: string[] };
setCurrentSelectedRepos(savedGitHubConfig.repo_full_names || []);
setOriginalPat(savedGitHubConfig.GITHUB_PAT || "");
setNewSelectedRepos(savedGitHubConfig.repo_full_names || []);
patForm.reset({ github_pat: savedGitHubConfig.GITHUB_PAT || "" });
} else if(connector.connector_type === 'SLACK_CONNECTOR') {
editForm.setValue('SLACK_BOT_TOKEN', newlySavedConfig.SLACK_BOT_TOKEN || "");
} else if(connector.connector_type === 'NOTION_CONNECTOR') {
editForm.setValue('NOTION_INTEGRATION_TOKEN', newlySavedConfig.NOTION_INTEGRATION_TOKEN || "");
} else if(connector.connector_type === 'SERPER_API') {
editForm.setValue('SERPER_API_KEY', newlySavedConfig.SERPER_API_KEY || "");
} else if(connector.connector_type === 'TAVILY_API') {
editForm.setValue('TAVILY_API_KEY', newlySavedConfig.TAVILY_API_KEY || "");
} else if(connector.connector_type === 'LINEAR_CONNECTOR') {
editForm.setValue('LINEAR_API_KEY', newlySavedConfig.LINEAR_API_KEY || "");
} else if(connector.connector_type === 'LINKUP_API') {
editForm.setValue('LINKUP_API_KEY', newlySavedConfig.LINKUP_API_KEY || "");
} else if(connector.connector_type === 'DISCORD_CONNECTOR') {
editForm.setValue('DISCORD_BOT_TOKEN', newlySavedConfig.DISCORD_BOT_TOKEN || "");
}
}
if (connector.connector_type === 'GITHUB_CONNECTOR') {
setEditMode('viewing');
setFetchedRepos(null);
}
// Resetting simple form values is handled by useEffect if connector state updates
} catch (error) {
console.error("Error updating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to update connector.");
} finally { setIsSaving(false); }
}, [connector, originalConfig, updateConnector, connectorId, patForm, originalPat, currentSelectedRepos, newSelectedRepos, editMode, fetchedRepos, editForm]); // Added editForm to dependencies
// Return values needed by the component
return {
connectorsLoading,
connector,
isSaving,
editForm,
patForm,
handleSaveChanges,
// GitHub specific props
editMode,
setEditMode,
originalPat,
currentSelectedRepos,
fetchedRepos,
setFetchedRepos,
newSelectedRepos,
setNewSelectedRepos,
isFetchingRepos,
handleFetchRepositories,
handleRepoSelectionChange,
};
}
// Return values needed by the component
return {
connectorsLoading,
connector,
isSaving,
editForm,
patForm,
handleSaveChanges,
// GitHub specific props
editMode,
setEditMode,
originalPat,
currentSelectedRepos,
fetchedRepos,
setFetchedRepos,
newSelectedRepos,
setNewSelectedRepos,
isFetchingRepos,
handleFetchRepositories,
handleRepoSelectionChange,
};
}